@team-agent/installer 0.5.68 → 0.5.69
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/diagnose.rs +74 -0
- package/crates/team-agent/src/cli/emit.rs +5 -4
- package/crates/team-agent/src/cli/leader.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +1 -0
- package/crates/team-agent/src/compiler.rs +62 -5
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
- package/crates/team-agent/src/layout/worker_window_helpers.rs +1 -0
- package/crates/team-agent/src/leader/start.rs +53 -12
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +67 -15
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +18 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +260 -44
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +5 -1
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +10 -59
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +830 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +9 -3
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +70 -28
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +10 -2
- package/crates/team-agent/src/lifecycle/profile_launch.rs +3 -2
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +90 -160
- package/crates/team-agent/src/lifecycle/restart/common.rs +146 -28
- package/crates/team-agent/src/lifecycle/restart/remove.rs +37 -1
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +195 -18
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +30 -12
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +5 -2
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +35 -48
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +128 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -0
- package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
- package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests.rs +7 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +61 -4
- package/crates/team-agent/src/messaging/delivery.rs +36 -12
- package/crates/team-agent/src/model/enums.rs +3 -1
- package/crates/team-agent/src/model/permissions.rs +1 -1
- package/crates/team-agent/src/provider/adapter.rs +29 -3
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +5 -2
- package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
- package/crates/team-agent/src/provider/adapters/pi.rs +152 -0
- package/crates/team-agent/src/provider/bypass_flags.rs +1 -0
- package/crates/team-agent/src/provider/classify.rs +2 -2
- package/crates/team-agent/src/provider/faults.rs +1 -1
- package/crates/team-agent/src/provider/session/context_fork.rs +2 -1
- package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
- package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
- package/crates/team-agent/src/provider/session_scan.rs +4 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +80 -0
- package/crates/team-agent/src/provider/wire.rs +7 -1
- package/crates/team-agent/src/tmux_backend.rs +52 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +2 -4
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
//! maturity: wired
|
|
38
38
|
//! ---
|
|
39
39
|
use super::*;
|
|
40
|
+
use crate::transport::Transport;
|
|
40
41
|
|
|
41
42
|
pub(super) struct SpawnedAgentWindow {
|
|
42
43
|
pub spawn: crate::transport::SpawnResult,
|
|
@@ -340,11 +341,15 @@ pub(super) fn spawn_agent_window(
|
|
|
340
341
|
agent_id.as_str(),
|
|
341
342
|
team_id.as_deref().unwrap_or(""),
|
|
342
343
|
);
|
|
343
|
-
let mcp_config_path =
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
344
|
+
let mcp_config_path = if provider == Provider::Pi {
|
|
345
|
+
None
|
|
346
|
+
} else {
|
|
347
|
+
Some(crate::lifecycle::launch::write_worker_mcp_config(
|
|
348
|
+
workspace,
|
|
349
|
+
agent_id.as_str(),
|
|
350
|
+
&mcp_config,
|
|
351
|
+
)?)
|
|
352
|
+
};
|
|
348
353
|
let profile_launch =
|
|
349
354
|
crate::lifecycle::profile_launch::prepare_provider_profile_launch_from_json(
|
|
350
355
|
workspace,
|
|
@@ -374,20 +379,75 @@ pub(super) fn spawn_agent_window(
|
|
|
374
379
|
agent_id_hint: Some(agent_id.as_str()),
|
|
375
380
|
effort: restart_effort,
|
|
376
381
|
};
|
|
377
|
-
let
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
let pi_spawn_cwd = (provider == Provider::Pi)
|
|
383
|
+
.then(|| {
|
|
384
|
+
agent
|
|
385
|
+
.get("spawn_cwd")
|
|
386
|
+
.and_then(serde_json::Value::as_str)
|
|
387
|
+
.filter(|value| !value.is_empty())
|
|
388
|
+
.map(PathBuf::from)
|
|
389
|
+
})
|
|
390
|
+
.flatten();
|
|
391
|
+
let spawn_cwd = spawn_cwd_override
|
|
392
|
+
.or(pi_spawn_cwd.as_deref())
|
|
393
|
+
.unwrap_or(workspace);
|
|
394
|
+
let mut plan = if provider == Provider::Pi {
|
|
395
|
+
let model = command_model.ok_or_else(|| {
|
|
396
|
+
LifecycleError::RequirementUnmet(
|
|
397
|
+
"Pi restart requires an explicit qualified model".to_string(),
|
|
398
|
+
)
|
|
399
|
+
})?;
|
|
400
|
+
let effort = restart_effort.ok_or_else(|| {
|
|
401
|
+
LifecycleError::RequirementUnmet(
|
|
402
|
+
"Pi restart requires an explicit thinking effort".to_string(),
|
|
403
|
+
)
|
|
404
|
+
})?;
|
|
405
|
+
let request = crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
|
|
406
|
+
workspace,
|
|
407
|
+
team_id: team_id.as_deref().unwrap_or(""),
|
|
408
|
+
agent_id: agent_id.as_str(),
|
|
409
|
+
model,
|
|
410
|
+
effort,
|
|
411
|
+
system_prompt: &system_prompt,
|
|
412
|
+
tool_categories: &resolved_tool_refs,
|
|
413
|
+
team_mcp_tools: &["send_message", "report_result"],
|
|
414
|
+
mcp_config: &mcp_config,
|
|
415
|
+
};
|
|
416
|
+
match resume_session_id {
|
|
417
|
+
Some(session_id) => {
|
|
418
|
+
let rollout_path = agent_rollout_path(agent).ok_or_else(|| {
|
|
419
|
+
LifecycleError::RequirementUnmet(
|
|
420
|
+
"Pi resume requires the persisted exact session path".to_string(),
|
|
421
|
+
)
|
|
422
|
+
})?;
|
|
423
|
+
crate::lifecycle::launch::pi_mcp::materialize_pi_resume_plan(
|
|
424
|
+
request,
|
|
425
|
+
session_id,
|
|
426
|
+
rollout_path.as_path(),
|
|
427
|
+
spawn_cwd,
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
None => crate::lifecycle::launch::pi_mcp::materialize_pi_plan(request),
|
|
431
|
+
}
|
|
432
|
+
.map_err(|e| LifecycleError::Provider(e.to_string()))?
|
|
433
|
+
} else {
|
|
434
|
+
match resume_session_id {
|
|
435
|
+
Some(session_id) => adapter
|
|
436
|
+
.build_resume_command_plan(Some(session_id), context)
|
|
437
|
+
.map_err(|e| LifecycleError::Provider(e.to_string()))?,
|
|
438
|
+
None => adapter
|
|
439
|
+
.build_command_plan(context)
|
|
440
|
+
.map_err(|e| LifecycleError::Provider(e.to_string()))?,
|
|
441
|
+
}
|
|
384
442
|
};
|
|
385
443
|
if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
444
|
+
if let Some(mcp_config_path) = mcp_config_path.as_ref() {
|
|
445
|
+
crate::lifecycle::launch::point_native_mcp_config_at_file(
|
|
446
|
+
&mut plan.argv,
|
|
447
|
+
provider,
|
|
448
|
+
mcp_config_path,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
391
451
|
}
|
|
392
452
|
crate::lifecycle::launch::fill_spawn_placeholders_full(
|
|
393
453
|
&mut plan.argv,
|
|
@@ -427,8 +487,20 @@ pub(super) fn spawn_agent_window(
|
|
|
427
487
|
&system_prompt,
|
|
428
488
|
)?;
|
|
429
489
|
crate::lifecycle::launch::apply_cursor_mcp_overlay(workspace, &mcp_config)?;
|
|
430
|
-
crate::lifecycle::launch::
|
|
431
|
-
|
|
490
|
+
let project = if crate::lifecycle::launch::cursor_mcp_isolation_enabled() {
|
|
491
|
+
Some(crate::lifecycle::launch::cursor_mcp_project_dir(
|
|
492
|
+
workspace,
|
|
493
|
+
agent_id.as_str(),
|
|
494
|
+
)?)
|
|
495
|
+
} else {
|
|
496
|
+
None
|
|
497
|
+
};
|
|
498
|
+
crate::lifecycle::launch::enable_cursor_workspace_mcp(workspace, project.as_deref())?;
|
|
499
|
+
crate::lifecycle::launch::apply_cursor_spawn_workspace_pointers(
|
|
500
|
+
&mut plan.argv,
|
|
501
|
+
workspace,
|
|
502
|
+
agent_id.as_str(),
|
|
503
|
+
)?;
|
|
432
504
|
crate::lifecycle::launch::apply_cursor_subscription_proxy_env(&mut env);
|
|
433
505
|
}
|
|
434
506
|
if provider == crate::provider::Provider::Grok {
|
|
@@ -446,8 +518,9 @@ pub(super) fn spawn_agent_window(
|
|
|
446
518
|
//
|
|
447
519
|
// NOTE: Step 4 will thread the YAML spec down to here so we can honour
|
|
448
520
|
// a per-agent YAML `spawn_cwd` field if one is set. Until then, override
|
|
449
|
-
// > workspace; state-based override is silently dropped
|
|
450
|
-
|
|
521
|
+
// > workspace; state-based override is silently dropped for existing
|
|
522
|
+
// providers. Pi is the narrow exception because exact header/path resume
|
|
523
|
+
// must preserve the captured cwd or refuse.
|
|
451
524
|
// 0.4.x provider effort MVP step 9: scrub CLAUDE_EFFORT for Claude
|
|
452
525
|
// worker spawn so a parent shell env cannot silently override the
|
|
453
526
|
// framework's effort decision.
|
|
@@ -912,6 +985,14 @@ pub(super) fn lifecycle_worker_tmux_backend_selection_for_state(
|
|
|
912
985
|
)));
|
|
913
986
|
}
|
|
914
987
|
}
|
|
988
|
+
if let Some((endpoint, source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
|
|
989
|
+
let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
|
|
990
|
+
return Ok(crate::tmux_backend::RuntimeTmuxBackendSelection {
|
|
991
|
+
tmux_endpoint_used: backend.tmux_endpoint(),
|
|
992
|
+
backend,
|
|
993
|
+
tmux_endpoint_source: source,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
915
996
|
Ok(
|
|
916
997
|
crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
917
998
|
run_workspace,
|
|
@@ -928,6 +1009,9 @@ pub(super) fn lifecycle_worker_tmux_backend_for_state(
|
|
|
928
1009
|
run_workspace: &Path,
|
|
929
1010
|
state: &serde_json::Value,
|
|
930
1011
|
) -> crate::tmux_backend::TmuxBackend {
|
|
1012
|
+
if let Some((endpoint, _source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
|
|
1013
|
+
return crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
|
|
1014
|
+
}
|
|
931
1015
|
crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(run_workspace, Some(state))
|
|
932
1016
|
.backend
|
|
933
1017
|
}
|
|
@@ -1091,12 +1175,34 @@ fn json_team_identity_matches(existing: &serde_json::Value, compact: &serde_json
|
|
|
1091
1175
|
/// returns: 非空的 session_name,缺失时退到默认名
|
|
1092
1176
|
/// ---
|
|
1093
1177
|
pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
|
|
1178
|
+
worker_session_name_from_state(state)
|
|
1179
|
+
.map(SessionName::new)
|
|
1180
|
+
.unwrap_or_else(|| SessionName::new("team-agent"))
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
fn worker_session_name_from_state(state: &serde_json::Value) -> Option<&str> {
|
|
1094
1184
|
state
|
|
1095
1185
|
.get("session_name")
|
|
1096
1186
|
.and_then(|v| v.as_str())
|
|
1097
1187
|
.filter(|s| !s.is_empty())
|
|
1098
|
-
.
|
|
1099
|
-
|
|
1188
|
+
.or_else(|| {
|
|
1189
|
+
let team_key = state
|
|
1190
|
+
.get("active_team_key")
|
|
1191
|
+
.and_then(serde_json::Value::as_str)
|
|
1192
|
+
.filter(|team_key| !team_key.is_empty())?;
|
|
1193
|
+
state
|
|
1194
|
+
.get("teams")?
|
|
1195
|
+
.get(team_key)?
|
|
1196
|
+
.pointer("/leader_receiver/session_name")?
|
|
1197
|
+
.as_str()
|
|
1198
|
+
.filter(|session| !session.is_empty())
|
|
1199
|
+
})
|
|
1200
|
+
.or_else(|| {
|
|
1201
|
+
state
|
|
1202
|
+
.pointer("/leader_receiver/session_name")
|
|
1203
|
+
.and_then(serde_json::Value::as_str)
|
|
1204
|
+
.filter(|session| !session.is_empty())
|
|
1205
|
+
})
|
|
1100
1206
|
}
|
|
1101
1207
|
|
|
1102
1208
|
/// ---
|
|
@@ -1104,11 +1210,7 @@ pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
|
|
|
1104
1210
|
/// returns: 记了则 true
|
|
1105
1211
|
/// ---
|
|
1106
1212
|
pub(super) fn session_name_present(state: &serde_json::Value) -> bool {
|
|
1107
|
-
state
|
|
1108
|
-
.get("session_name")
|
|
1109
|
-
.and_then(|v| v.as_str())
|
|
1110
|
-
.map(|s| !s.is_empty())
|
|
1111
|
-
.unwrap_or(false)
|
|
1213
|
+
worker_session_name_from_state(state).is_some()
|
|
1112
1214
|
}
|
|
1113
1215
|
|
|
1114
1216
|
/// ---
|
|
@@ -1349,6 +1451,22 @@ pub(super) fn resume_backing_probe_for_agent(
|
|
|
1349
1451
|
crate::provider::session_scan::cursor::cursor_session_archive_present(dir)
|
|
1350
1452
|
})
|
|
1351
1453
|
}
|
|
1454
|
+
Provider::Pi => {
|
|
1455
|
+
let spawn_cwd = agent
|
|
1456
|
+
.get("spawn_cwd")
|
|
1457
|
+
.and_then(serde_json::Value::as_str)
|
|
1458
|
+
.filter(|value| !value.is_empty())
|
|
1459
|
+
.map(PathBuf::from)
|
|
1460
|
+
.unwrap_or_else(|| workspace.to_path_buf());
|
|
1461
|
+
rollout_path.is_some_and(|path| {
|
|
1462
|
+
crate::provider::session_scan::pi::validate_exact_backing(
|
|
1463
|
+
path.as_path(),
|
|
1464
|
+
session_id,
|
|
1465
|
+
&spawn_cwd,
|
|
1466
|
+
)
|
|
1467
|
+
.is_ok()
|
|
1468
|
+
})
|
|
1469
|
+
}
|
|
1352
1470
|
Provider::GeminiCli | Provider::Fake => false,
|
|
1353
1471
|
};
|
|
1354
1472
|
|
|
@@ -539,6 +539,22 @@ fn remove_agent_at_paths(
|
|
|
539
539
|
}
|
|
540
540
|
});
|
|
541
541
|
}
|
|
542
|
+
let pi_cleanup_paths = preflight
|
|
543
|
+
.seat
|
|
544
|
+
.state
|
|
545
|
+
.get("agents")
|
|
546
|
+
.and_then(|agents| agents.get(agent_id.as_str()))
|
|
547
|
+
.and_then(|agent| agent.get("provider"))
|
|
548
|
+
.and_then(serde_json::Value::as_str)
|
|
549
|
+
.and_then(crate::provider::wire::parse_provider)
|
|
550
|
+
.filter(|provider| *provider == Provider::Pi)
|
|
551
|
+
.map(|_| {
|
|
552
|
+
crate::lifecycle::launch::pi_mcp::pi_seat_paths(
|
|
553
|
+
workspace,
|
|
554
|
+
&preflight.seat.team_key,
|
|
555
|
+
agent_id.as_str(),
|
|
556
|
+
)
|
|
557
|
+
});
|
|
542
558
|
let paths = LifecyclePathRefs {
|
|
543
559
|
run_workspace: workspace,
|
|
544
560
|
spec_workspace,
|
|
@@ -574,7 +590,27 @@ fn remove_agent_at_paths(
|
|
|
574
590
|
Ok(success)
|
|
575
591
|
});
|
|
576
592
|
match result {
|
|
577
|
-
Ok(success) => {
|
|
593
|
+
Ok(mut success) => {
|
|
594
|
+
if let Some(pi_paths) = pi_cleanup_paths.as_ref() {
|
|
595
|
+
let cleanup = crate::lifecycle::launch::pi_mcp::pi_cleanup_plan(
|
|
596
|
+
crate::lifecycle::launch::pi_mcp::PiCleanupAction::Remove,
|
|
597
|
+
pi_paths,
|
|
598
|
+
);
|
|
599
|
+
for path in cleanup.delete_paths {
|
|
600
|
+
match std::fs::remove_file(&path) {
|
|
601
|
+
Ok(()) => success
|
|
602
|
+
.cleared_locations
|
|
603
|
+
.push(serde_json::json!(path.to_string_lossy().to_string())),
|
|
604
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
605
|
+
Err(error) => {
|
|
606
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
607
|
+
"remove-agent completed logical removal for {agent_id}, but exact Pi wrapper cleanup failed at {}: {error}",
|
|
608
|
+
path.display()
|
|
609
|
+
)));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
578
614
|
// Foundation-0 F0-2: the historical dual-write to the legacy
|
|
579
615
|
// per-session snapshot has been retired
|
|
580
616
|
// (`.team/artifacts/foundation-0-slice-design.md` §§4-5).
|
|
@@ -133,19 +133,40 @@ fn launch_restart_start_add_and_fork_all_delegate_to_the_profile_launch_resolver
|
|
|
133
133
|
("launch quick-start", &launch, "spawn_agents"),
|
|
134
134
|
("add-agent", &launch, "add_agent_with_transport"),
|
|
135
135
|
("fork-agent", &launch, "fork_agent_with_transport"),
|
|
136
|
-
(
|
|
137
|
-
|
|
136
|
+
(
|
|
137
|
+
"restart/start shared spawn",
|
|
138
|
+
&restart_common,
|
|
139
|
+
"pub(super) fn spawn_agent_window(",
|
|
140
|
+
),
|
|
138
141
|
];
|
|
139
142
|
for (surface, source, entry) in surfaces {
|
|
140
143
|
let section = source
|
|
141
144
|
.find(entry)
|
|
142
145
|
.map(|idx| &source[idx..])
|
|
143
|
-
.
|
|
146
|
+
.unwrap_or_else(|| panic!("{surface} entry missing: {entry}"));
|
|
144
147
|
assert!(
|
|
145
148
|
section.contains("prepare_provider_profile_launch"),
|
|
146
149
|
"{surface} must call the single profile resolver before building/spawning provider argv; entry={entry}"
|
|
147
150
|
);
|
|
148
151
|
}
|
|
152
|
+
|
|
153
|
+
let start_agent = restart_agent
|
|
154
|
+
.find("pub(crate) fn start_agent_at_paths(")
|
|
155
|
+
.map(|idx| &restart_agent[idx..])
|
|
156
|
+
.unwrap_or_else(|| panic!("start-agent entry missing: start_agent_at_paths"));
|
|
157
|
+
assert!(
|
|
158
|
+
start_agent.contains("spawn_agent_window("),
|
|
159
|
+
"start-agent must delegate provider argv construction to the shared restart/start spawn path"
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
let spawn_agent_window = restart_common
|
|
163
|
+
.find("pub(super) fn spawn_agent_window(")
|
|
164
|
+
.map(|idx| &restart_common[idx..])
|
|
165
|
+
.unwrap_or_else(|| panic!("restart/start shared spawn entry missing"));
|
|
166
|
+
assert!(
|
|
167
|
+
spawn_agent_window.contains("prepare_provider_profile_launch_from_json("),
|
|
168
|
+
"shared restart/start spawn must call the JSON profile launch resolver"
|
|
169
|
+
);
|
|
149
170
|
}
|
|
150
171
|
|
|
151
172
|
#[test]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//! purpose: cursor 席位必须把身份写进它实际会读的 mcp.json env
|
|
2
|
-
//! contract: overlay 后
|
|
3
|
-
//!
|
|
2
|
+
//! contract: overlay 后 provider-config/<id>/cursor/.cursor/mcp.json 含 team_orchestrator.env.TEAM_AGENT_ID
|
|
3
|
+
//! (不是改 HOME,也不是靠 pane env 继承)
|
|
4
4
|
//! boundary: 只覆盖 cursor launch 产物;不改 claude/codex/copilot/grok 路径
|
|
5
5
|
//!
|
|
6
6
|
//! 生产侧判据,未经血统审计。
|
|
@@ -16,21 +16,34 @@ use std::path::{Path, PathBuf};
|
|
|
16
16
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
17
17
|
|
|
18
18
|
use serial_test::serial;
|
|
19
|
+
use sha2::{Digest, Sha256};
|
|
19
20
|
use team_agent::lifecycle::quick_start_with_transport_in_workspace;
|
|
20
21
|
use team_agent::lifecycle::{
|
|
21
|
-
apply_cursor_mcp_overlay,
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
apply_cursor_mcp_overlay, apply_cursor_spawn_workspace_pointers,
|
|
23
|
+
apply_cursor_subscription_proxy_env, apply_cursor_workspace_physical_path,
|
|
24
|
+
cursor_mcp_enable_argv, cursor_mcp_json_path, cursor_mcp_project_dir, physical_workspace_path,
|
|
25
|
+
refuse_second_cursor_occupant, LifecycleError,
|
|
24
26
|
};
|
|
25
27
|
use team_agent::provider::McpConfig;
|
|
26
28
|
use team_agent::transport::test_support::OfflineTransport;
|
|
27
29
|
|
|
28
30
|
#[test]
|
|
31
|
+
#[serial(cursor_mcp_isolation)]
|
|
29
32
|
fn cursor_overlay_writes_identity_into_mcp_json_env() {
|
|
30
33
|
let ws = tmp_dir("cursor-mcp-id");
|
|
31
34
|
apply_cursor_mcp_overlay(&ws, &sample_mcp_config("seat-a", &ws.to_string_lossy()))
|
|
32
35
|
.expect("overlay write");
|
|
33
|
-
let
|
|
36
|
+
let path = cursor_mcp_json_path(&ws, "seat-a").unwrap();
|
|
37
|
+
let rendered = path.to_string_lossy();
|
|
38
|
+
assert!(
|
|
39
|
+
rendered.contains("provider-config") && rendered.contains("/cursor/"),
|
|
40
|
+
"identity must land under provider-config/<id>/cursor, not a HOME fork: {rendered}"
|
|
41
|
+
);
|
|
42
|
+
assert!(
|
|
43
|
+
!rendered.contains("/.team/runtime/cursor-mcp/"),
|
|
44
|
+
"must not use the rejected COPILOT_HOME-style cursor-mcp tree: {rendered}"
|
|
45
|
+
);
|
|
46
|
+
let text = std::fs::read_to_string(&path).unwrap();
|
|
34
47
|
assert!(
|
|
35
48
|
text.contains("\"team_orchestrator\""),
|
|
36
49
|
"cursor overlay must use the canonical server name"
|
|
@@ -58,6 +71,7 @@ fn cursor_overlay_writes_identity_into_mcp_json_env() {
|
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
#[test]
|
|
74
|
+
#[serial(cursor_mcp_isolation)]
|
|
61
75
|
fn cursor_overlay_refuses_missing_identity() {
|
|
62
76
|
let ws = tmp_dir("cursor-mcp-missing-id");
|
|
63
77
|
let cfg = McpConfig {
|
|
@@ -82,6 +96,7 @@ fn cursor_overlay_refuses_missing_identity() {
|
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
#[test]
|
|
99
|
+
#[serial(cursor_mcp_isolation)]
|
|
85
100
|
fn cursor_overlay_keeps_unrelated_servers_and_replaces_orchestrator() {
|
|
86
101
|
let ws = tmp_dir("cursor-mcp-merge");
|
|
87
102
|
let dir = ws.join(".cursor");
|
|
@@ -99,38 +114,63 @@ fn cursor_overlay_keeps_unrelated_servers_and_replaces_orchestrator() {
|
|
|
99
114
|
.unwrap();
|
|
100
115
|
|
|
101
116
|
apply_cursor_mcp_overlay(&ws, &sample_mcp_config("seat-b", "/ws-b")).expect("overlay merge");
|
|
102
|
-
let
|
|
117
|
+
let project = std::fs::read_to_string(dir.join("mcp.json")).unwrap();
|
|
103
118
|
assert!(
|
|
104
|
-
|
|
105
|
-
"unrelated project MCP servers must survive"
|
|
119
|
+
project.contains("keep-me"),
|
|
120
|
+
"unrelated project MCP servers must survive in workspace mcp.json"
|
|
106
121
|
);
|
|
107
122
|
assert!(
|
|
108
|
-
!
|
|
109
|
-
"stale orchestrator/legacy names must be
|
|
123
|
+
!project.contains("/old/team-agent") && !project.contains("/stale"),
|
|
124
|
+
"stale orchestrator/legacy names must be scrubbed from the shared project file"
|
|
110
125
|
);
|
|
126
|
+
let isolated = std::fs::read_to_string(cursor_mcp_json_path(&ws, "seat-b").unwrap()).unwrap();
|
|
111
127
|
assert!(
|
|
112
|
-
|
|
113
|
-
"new identity must land"
|
|
128
|
+
isolated.contains("seat-b") && isolated.contains("/ws-b"),
|
|
129
|
+
"new identity must land in the per-seat mcp.json"
|
|
114
130
|
);
|
|
115
131
|
}
|
|
116
132
|
|
|
117
133
|
#[test]
|
|
118
|
-
|
|
134
|
+
#[serial(cursor_mcp_isolation)]
|
|
135
|
+
fn cursor_second_seat_keeps_both_identities_when_isolated() {
|
|
119
136
|
let ws = tmp_dir("cursor-mcp-two");
|
|
120
137
|
apply_cursor_mcp_overlay(&ws, &sample_mcp_config("first", "/ws")).expect("first");
|
|
121
138
|
apply_cursor_mcp_overlay(&ws, &sample_mcp_config("second", "/ws")).expect("second");
|
|
139
|
+
let first = std::fs::read_to_string(cursor_mcp_json_path(&ws, "first").unwrap()).unwrap();
|
|
140
|
+
let second = std::fs::read_to_string(cursor_mcp_json_path(&ws, "second").unwrap()).unwrap();
|
|
141
|
+
assert!(
|
|
142
|
+
first.contains("\"first\"") && !first.contains("\"second\""),
|
|
143
|
+
"seat first must keep its TEAM_AGENT_ID"
|
|
144
|
+
);
|
|
145
|
+
assert!(
|
|
146
|
+
second.contains("\"second\"") && !second.contains("\"first\""),
|
|
147
|
+
"seat second must keep its TEAM_AGENT_ID"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[test]
|
|
152
|
+
#[serial(cursor_mcp_isolation)]
|
|
153
|
+
#[serial(env)]
|
|
154
|
+
fn cursor_shared_overlay_last_writer_is_the_destruction_tooth() {
|
|
155
|
+
let previous = std::env::var("TEAM_AGENT_CURSOR_MCP_ISOLATION").ok();
|
|
156
|
+
std::env::set_var("TEAM_AGENT_CURSOR_MCP_ISOLATION", "0");
|
|
157
|
+
let ws = tmp_dir("cursor-mcp-shared-red");
|
|
158
|
+
apply_cursor_mcp_overlay(&ws, &sample_mcp_config("first", "/ws")).expect("first");
|
|
159
|
+
apply_cursor_mcp_overlay(&ws, &sample_mcp_config("second", "/ws")).expect("second");
|
|
122
160
|
let text = std::fs::read_to_string(ws.join(".cursor/mcp.json")).unwrap();
|
|
161
|
+
restore_isolation_env(previous);
|
|
123
162
|
assert!(
|
|
124
163
|
text.contains("second"),
|
|
125
|
-
"
|
|
164
|
+
"destruction: shared mcp.json last writer is second"
|
|
126
165
|
);
|
|
127
166
|
assert!(
|
|
128
167
|
!text.contains("\"first\""),
|
|
129
|
-
"
|
|
168
|
+
"destruction: criterion 2 goes red — first identity is gone"
|
|
130
169
|
);
|
|
131
170
|
}
|
|
132
171
|
|
|
133
172
|
#[test]
|
|
173
|
+
#[serial(cursor_mcp_isolation)]
|
|
134
174
|
fn cursor_enable_argv_is_documented_subcommand_without_workspace_flag() {
|
|
135
175
|
let argv = cursor_mcp_enable_argv();
|
|
136
176
|
assert_eq!(
|
|
@@ -145,6 +185,72 @@ fn cursor_enable_argv_is_documented_subcommand_without_workspace_flag() {
|
|
|
145
185
|
}
|
|
146
186
|
|
|
147
187
|
#[test]
|
|
188
|
+
#[serial(cursor_mcp_isolation)]
|
|
189
|
+
fn cursor_enable_failure_preserves_cwd_config_identity_and_redacted_stderr() {
|
|
190
|
+
let ws = tmp_dir("cursor-enable-diagnostic");
|
|
191
|
+
apply_cursor_mcp_overlay(
|
|
192
|
+
&ws,
|
|
193
|
+
&sample_mcp_config("diagnostic-seat", &ws.to_string_lossy()),
|
|
194
|
+
)
|
|
195
|
+
.expect("write fake cursor overlay");
|
|
196
|
+
let cwd = physical_workspace_path(&cursor_mcp_project_dir(&ws, "diagnostic-seat").unwrap());
|
|
197
|
+
let config_path = cwd.join(".cursor/mcp.json");
|
|
198
|
+
let config_body = std::fs::read(&config_path).expect("read fake overlay identity");
|
|
199
|
+
let config_metadata = std::fs::metadata(&config_path).expect("fake overlay metadata");
|
|
200
|
+
let expected_hash = format!("{:x}", Sha256::digest(&config_body));
|
|
201
|
+
let benign = format!("benign first-enable diagnostic {}", "x".repeat(700));
|
|
202
|
+
let secret_value = "must-not-leak-secret-value";
|
|
203
|
+
let stderr =
|
|
204
|
+
format!("Authorization: Bearer sk-{secret_value} token={secret_value}\n{benign}\n");
|
|
205
|
+
let argv = cursor_mcp_enable_argv();
|
|
206
|
+
let error = LifecycleError::cursor_mcp_enable_failure(
|
|
207
|
+
&argv,
|
|
208
|
+
&cwd,
|
|
209
|
+
Some(23),
|
|
210
|
+
b"out",
|
|
211
|
+
stderr.as_bytes(),
|
|
212
|
+
None,
|
|
213
|
+
);
|
|
214
|
+
let LifecycleError::RequirementUnmet(report) = error else {
|
|
215
|
+
panic!("expected RequirementUnmet diagnostic")
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
assert!(report.contains("argv: agent mcp enable team_orchestrator"));
|
|
219
|
+
assert!(report.contains(&format!("cwd: {}", cwd.display())));
|
|
220
|
+
assert!(report.contains(&format!("config_path: {}", config_path.display())));
|
|
221
|
+
assert!(report.contains("config_exists: true"));
|
|
222
|
+
assert!(report.contains(&format!("config_size: {}", config_body.len())));
|
|
223
|
+
assert!(report.contains(&format!("config_sha256: {expected_hash}")));
|
|
224
|
+
assert!(report.contains("stdout_len: 3"));
|
|
225
|
+
assert!(report.contains(&format!("stderr_len: {}", stderr.len())));
|
|
226
|
+
#[cfg(unix)]
|
|
227
|
+
{
|
|
228
|
+
use std::os::unix::fs::PermissionsExt;
|
|
229
|
+
assert!(report.contains(&format!(
|
|
230
|
+
"config_mode: 0o{:o}",
|
|
231
|
+
config_metadata.permissions().mode()
|
|
232
|
+
)));
|
|
233
|
+
}
|
|
234
|
+
#[cfg(not(unix))]
|
|
235
|
+
assert!(report.contains("config_mode: unavailable_non_unix"));
|
|
236
|
+
|
|
237
|
+
let safe = report
|
|
238
|
+
.lines()
|
|
239
|
+
.find_map(|line| line.strip_prefix("stderr_first_safe: "))
|
|
240
|
+
.expect("bounded benign stderr must be retained");
|
|
241
|
+
assert!(safe.starts_with("benign first-enable diagnostic"));
|
|
242
|
+
assert_eq!(safe.len(), 512, "safe stderr must be capped at 512 bytes");
|
|
243
|
+
assert!(!report.contains("Authorization"));
|
|
244
|
+
assert!(!report.contains("Bearer"));
|
|
245
|
+
assert!(!report.contains(secret_value));
|
|
246
|
+
assert!(
|
|
247
|
+
!report.contains("TEAM_AGENT_AUTH_MODE"),
|
|
248
|
+
"diagnostic must not include overlay JSON contents"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#[test]
|
|
253
|
+
#[serial(cursor_mcp_isolation)]
|
|
148
254
|
fn cursor_workspace_flag_is_rewritten_to_physical_path() {
|
|
149
255
|
let ws = tmp_dir("cursor-phys");
|
|
150
256
|
let mut argv = vec![
|
|
@@ -158,6 +264,29 @@ fn cursor_workspace_flag_is_rewritten_to_physical_path() {
|
|
|
158
264
|
}
|
|
159
265
|
|
|
160
266
|
#[test]
|
|
267
|
+
#[serial(cursor_mcp_isolation)]
|
|
268
|
+
fn cursor_spawn_pointers_use_per_seat_workspace_and_add_dir() {
|
|
269
|
+
let ws = tmp_dir("cursor-pointers");
|
|
270
|
+
apply_cursor_mcp_overlay(&ws, &sample_mcp_config("seat-p", &ws.to_string_lossy()))
|
|
271
|
+
.expect("overlay");
|
|
272
|
+
let mut argv = vec![
|
|
273
|
+
"agent".to_string(),
|
|
274
|
+
"--workspace".to_string(),
|
|
275
|
+
ws.to_string_lossy().into_owned(),
|
|
276
|
+
];
|
|
277
|
+
apply_cursor_spawn_workspace_pointers(&mut argv, &ws, "seat-p").expect("pointers");
|
|
278
|
+
let project = physical_workspace_path(&cursor_mcp_project_dir(&ws, "seat-p").unwrap());
|
|
279
|
+
let team = physical_workspace_path(&ws);
|
|
280
|
+
assert_eq!(argv[2], project.to_string_lossy().as_ref());
|
|
281
|
+
assert!(
|
|
282
|
+
argv.windows(2)
|
|
283
|
+
.any(|pair| pair[0] == "--add-dir" && pair[1] == team.to_string_lossy().as_ref()),
|
|
284
|
+
"true workspace must be added with documented --add-dir: {argv:?}"
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[test]
|
|
289
|
+
#[serial(cursor_mcp_isolation)]
|
|
161
290
|
#[serial(env)]
|
|
162
291
|
fn cursor_subscription_proxy_copies_keys_without_requiring_profile() {
|
|
163
292
|
let prev = std::env::var("HTTPS_PROXY").ok();
|
|
@@ -183,10 +312,11 @@ fn cursor_subscription_proxy_copies_keys_without_requiring_profile() {
|
|
|
183
312
|
}
|
|
184
313
|
|
|
185
314
|
#[test]
|
|
315
|
+
#[serial(cursor_mcp_isolation)]
|
|
186
316
|
fn cursor_spawn_writes_identity_into_project_mcp_json() {
|
|
187
317
|
let ws = tmp_dir("cursor-mcp-spawn");
|
|
188
318
|
let team = write_cursor_team(&ws, "cursortm", "cursor_writer");
|
|
189
|
-
let config_path = ws
|
|
319
|
+
let config_path = cursor_mcp_json_path(&ws, "cursor_writer").expect("iso path");
|
|
190
320
|
assert!(
|
|
191
321
|
!config_path.exists(),
|
|
192
322
|
"precondition: project cursor mcp.json must be absent before spawn"
|
|
@@ -204,7 +334,7 @@ fn cursor_spawn_writes_identity_into_project_mcp_json() {
|
|
|
204
334
|
|
|
205
335
|
let text = std::fs::read_to_string(&config_path).unwrap_or_else(|err| {
|
|
206
336
|
panic!(
|
|
207
|
-
"cursor spawn must materialize {}; cursor
|
|
337
|
+
"cursor spawn must materialize per-seat {}; cursor reads --workspace/.cursor/mcp.json: {err}",
|
|
208
338
|
config_path.display()
|
|
209
339
|
)
|
|
210
340
|
});
|
|
@@ -285,3 +415,50 @@ fn tmp_dir(tag: &str) -> PathBuf {
|
|
|
285
415
|
std::fs::create_dir_all(&dir).unwrap();
|
|
286
416
|
std::fs::canonicalize(dir).unwrap()
|
|
287
417
|
}
|
|
418
|
+
|
|
419
|
+
fn restore_isolation_env(previous: Option<String>) {
|
|
420
|
+
match previous {
|
|
421
|
+
Some(value) => std::env::set_var("TEAM_AGENT_CURSOR_MCP_ISOLATION", value),
|
|
422
|
+
None => std::env::remove_var("TEAM_AGENT_CURSOR_MCP_ISOLATION"),
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#[test]
|
|
427
|
+
#[serial(cursor_mcp_isolation)]
|
|
428
|
+
fn refuse_second_allows_when_isolation_on() {
|
|
429
|
+
let ws = tmp_dir("cursor-refuse-iso-on");
|
|
430
|
+
let spec = team_agent::model::yaml::loads(
|
|
431
|
+
"agents:\n - id: a\n provider: cursor_agent\n - id: b\n provider: cursor_agent\n",
|
|
432
|
+
)
|
|
433
|
+
.expect("spec");
|
|
434
|
+
refuse_second_cursor_occupant(&ws, "b", Some(&spec)).expect("isolation on allows second");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#[test]
|
|
438
|
+
#[serial(cursor_mcp_isolation)]
|
|
439
|
+
#[serial(env)]
|
|
440
|
+
fn refuse_second_still_blocks_when_isolation_disabled() {
|
|
441
|
+
let previous = std::env::var("TEAM_AGENT_CURSOR_MCP_ISOLATION").ok();
|
|
442
|
+
std::env::set_var("TEAM_AGENT_CURSOR_MCP_ISOLATION", "0");
|
|
443
|
+
let ws = tmp_dir("cursor-refuse-iso-off");
|
|
444
|
+
let spec = team_agent::model::yaml::loads(
|
|
445
|
+
"agents:\n - id: a\n provider: cursor_agent\n - id: b\n provider: cursor_agent\n",
|
|
446
|
+
)
|
|
447
|
+
.expect("spec");
|
|
448
|
+
let err = refuse_second_cursor_occupant(&ws, "b", Some(&spec));
|
|
449
|
+
restore_isolation_env(previous);
|
|
450
|
+
let err = err.expect_err("isolation off must fail-closed");
|
|
451
|
+
match err {
|
|
452
|
+
LifecycleError::RequirementUnmet(text) => {
|
|
453
|
+
assert!(
|
|
454
|
+
text.contains("cursor_agent seat already occupies this workspace"),
|
|
455
|
+
"fail-closed text must stay: {text}"
|
|
456
|
+
);
|
|
457
|
+
assert!(
|
|
458
|
+
text.contains("last-writer"),
|
|
459
|
+
"reason must still name last-writer: {text}"
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
other => panic!("expected RequirementUnmet, got {other:?}"),
|
|
463
|
+
}
|
|
464
|
+
}
|