@team-agent/installer 0.5.68 → 0.5.70
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 +54 -7
- 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 +838 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +9 -3
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +60 -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 +136 -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 +145 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +243 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +856 -0
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +387 -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 +146 -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 +9 -5
|
@@ -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,65 @@ 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 request = crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
|
|
396
|
+
workspace,
|
|
397
|
+
team_id: team_id.as_deref().unwrap_or(""),
|
|
398
|
+
agent_id: agent_id.as_str(),
|
|
399
|
+
model: command_model,
|
|
400
|
+
effort: restart_effort,
|
|
401
|
+
system_prompt: &system_prompt,
|
|
402
|
+
tool_categories: &resolved_tool_refs,
|
|
403
|
+
team_mcp_tools: &["send_message", "report_result"],
|
|
404
|
+
mcp_config: &mcp_config,
|
|
405
|
+
};
|
|
406
|
+
match resume_session_id {
|
|
407
|
+
Some(session_id) => {
|
|
408
|
+
let rollout_path = agent_rollout_path(agent).ok_or_else(|| {
|
|
409
|
+
LifecycleError::RequirementUnmet(
|
|
410
|
+
"Pi resume requires the persisted exact session path".to_string(),
|
|
411
|
+
)
|
|
412
|
+
})?;
|
|
413
|
+
crate::lifecycle::launch::pi_mcp::materialize_pi_resume_plan(
|
|
414
|
+
request,
|
|
415
|
+
session_id,
|
|
416
|
+
rollout_path.as_path(),
|
|
417
|
+
spawn_cwd,
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
None => crate::lifecycle::launch::pi_mcp::materialize_pi_plan(request),
|
|
421
|
+
}
|
|
422
|
+
.map_err(|e| LifecycleError::Provider(e.to_string()))?
|
|
423
|
+
} else {
|
|
424
|
+
match resume_session_id {
|
|
425
|
+
Some(session_id) => adapter
|
|
426
|
+
.build_resume_command_plan(Some(session_id), context)
|
|
427
|
+
.map_err(|e| LifecycleError::Provider(e.to_string()))?,
|
|
428
|
+
None => adapter
|
|
429
|
+
.build_command_plan(context)
|
|
430
|
+
.map_err(|e| LifecycleError::Provider(e.to_string()))?,
|
|
431
|
+
}
|
|
384
432
|
};
|
|
385
433
|
if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
434
|
+
if let Some(mcp_config_path) = mcp_config_path.as_ref() {
|
|
435
|
+
crate::lifecycle::launch::point_native_mcp_config_at_file(
|
|
436
|
+
&mut plan.argv,
|
|
437
|
+
provider,
|
|
438
|
+
mcp_config_path,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
391
441
|
}
|
|
392
442
|
crate::lifecycle::launch::fill_spawn_placeholders_full(
|
|
393
443
|
&mut plan.argv,
|
|
@@ -427,8 +477,20 @@ pub(super) fn spawn_agent_window(
|
|
|
427
477
|
&system_prompt,
|
|
428
478
|
)?;
|
|
429
479
|
crate::lifecycle::launch::apply_cursor_mcp_overlay(workspace, &mcp_config)?;
|
|
430
|
-
crate::lifecycle::launch::
|
|
431
|
-
|
|
480
|
+
let project = if crate::lifecycle::launch::cursor_mcp_isolation_enabled() {
|
|
481
|
+
Some(crate::lifecycle::launch::cursor_mcp_project_dir(
|
|
482
|
+
workspace,
|
|
483
|
+
agent_id.as_str(),
|
|
484
|
+
)?)
|
|
485
|
+
} else {
|
|
486
|
+
None
|
|
487
|
+
};
|
|
488
|
+
crate::lifecycle::launch::enable_cursor_workspace_mcp(workspace, project.as_deref())?;
|
|
489
|
+
crate::lifecycle::launch::apply_cursor_spawn_workspace_pointers(
|
|
490
|
+
&mut plan.argv,
|
|
491
|
+
workspace,
|
|
492
|
+
agent_id.as_str(),
|
|
493
|
+
)?;
|
|
432
494
|
crate::lifecycle::launch::apply_cursor_subscription_proxy_env(&mut env);
|
|
433
495
|
}
|
|
434
496
|
if provider == crate::provider::Provider::Grok {
|
|
@@ -446,8 +508,9 @@ pub(super) fn spawn_agent_window(
|
|
|
446
508
|
//
|
|
447
509
|
// NOTE: Step 4 will thread the YAML spec down to here so we can honour
|
|
448
510
|
// a per-agent YAML `spawn_cwd` field if one is set. Until then, override
|
|
449
|
-
// > workspace; state-based override is silently dropped
|
|
450
|
-
|
|
511
|
+
// > workspace; state-based override is silently dropped for existing
|
|
512
|
+
// providers. Pi is the narrow exception because exact header/path resume
|
|
513
|
+
// must preserve the captured cwd or refuse.
|
|
451
514
|
// 0.4.x provider effort MVP step 9: scrub CLAUDE_EFFORT for Claude
|
|
452
515
|
// worker spawn so a parent shell env cannot silently override the
|
|
453
516
|
// framework's effort decision.
|
|
@@ -912,6 +975,14 @@ pub(super) fn lifecycle_worker_tmux_backend_selection_for_state(
|
|
|
912
975
|
)));
|
|
913
976
|
}
|
|
914
977
|
}
|
|
978
|
+
if let Some((endpoint, source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
|
|
979
|
+
let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
|
|
980
|
+
return Ok(crate::tmux_backend::RuntimeTmuxBackendSelection {
|
|
981
|
+
tmux_endpoint_used: backend.tmux_endpoint(),
|
|
982
|
+
backend,
|
|
983
|
+
tmux_endpoint_source: source,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
915
986
|
Ok(
|
|
916
987
|
crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
917
988
|
run_workspace,
|
|
@@ -928,6 +999,9 @@ pub(super) fn lifecycle_worker_tmux_backend_for_state(
|
|
|
928
999
|
run_workspace: &Path,
|
|
929
1000
|
state: &serde_json::Value,
|
|
930
1001
|
) -> crate::tmux_backend::TmuxBackend {
|
|
1002
|
+
if let Some((endpoint, _source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
|
|
1003
|
+
return crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
|
|
1004
|
+
}
|
|
931
1005
|
crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(run_workspace, Some(state))
|
|
932
1006
|
.backend
|
|
933
1007
|
}
|
|
@@ -1091,12 +1165,34 @@ fn json_team_identity_matches(existing: &serde_json::Value, compact: &serde_json
|
|
|
1091
1165
|
/// returns: 非空的 session_name,缺失时退到默认名
|
|
1092
1166
|
/// ---
|
|
1093
1167
|
pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
|
|
1168
|
+
worker_session_name_from_state(state)
|
|
1169
|
+
.map(SessionName::new)
|
|
1170
|
+
.unwrap_or_else(|| SessionName::new("team-agent"))
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
fn worker_session_name_from_state(state: &serde_json::Value) -> Option<&str> {
|
|
1094
1174
|
state
|
|
1095
1175
|
.get("session_name")
|
|
1096
1176
|
.and_then(|v| v.as_str())
|
|
1097
1177
|
.filter(|s| !s.is_empty())
|
|
1098
|
-
.
|
|
1099
|
-
|
|
1178
|
+
.or_else(|| {
|
|
1179
|
+
let team_key = state
|
|
1180
|
+
.get("active_team_key")
|
|
1181
|
+
.and_then(serde_json::Value::as_str)
|
|
1182
|
+
.filter(|team_key| !team_key.is_empty())?;
|
|
1183
|
+
state
|
|
1184
|
+
.get("teams")?
|
|
1185
|
+
.get(team_key)?
|
|
1186
|
+
.pointer("/leader_receiver/session_name")?
|
|
1187
|
+
.as_str()
|
|
1188
|
+
.filter(|session| !session.is_empty())
|
|
1189
|
+
})
|
|
1190
|
+
.or_else(|| {
|
|
1191
|
+
state
|
|
1192
|
+
.pointer("/leader_receiver/session_name")
|
|
1193
|
+
.and_then(serde_json::Value::as_str)
|
|
1194
|
+
.filter(|session| !session.is_empty())
|
|
1195
|
+
})
|
|
1100
1196
|
}
|
|
1101
1197
|
|
|
1102
1198
|
/// ---
|
|
@@ -1104,11 +1200,7 @@ pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
|
|
|
1104
1200
|
/// returns: 记了则 true
|
|
1105
1201
|
/// ---
|
|
1106
1202
|
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)
|
|
1203
|
+
worker_session_name_from_state(state).is_some()
|
|
1112
1204
|
}
|
|
1113
1205
|
|
|
1114
1206
|
/// ---
|
|
@@ -1349,6 +1441,22 @@ pub(super) fn resume_backing_probe_for_agent(
|
|
|
1349
1441
|
crate::provider::session_scan::cursor::cursor_session_archive_present(dir)
|
|
1350
1442
|
})
|
|
1351
1443
|
}
|
|
1444
|
+
Provider::Pi => {
|
|
1445
|
+
let spawn_cwd = agent
|
|
1446
|
+
.get("spawn_cwd")
|
|
1447
|
+
.and_then(serde_json::Value::as_str)
|
|
1448
|
+
.filter(|value| !value.is_empty())
|
|
1449
|
+
.map(PathBuf::from)
|
|
1450
|
+
.unwrap_or_else(|| workspace.to_path_buf());
|
|
1451
|
+
rollout_path.is_some_and(|path| {
|
|
1452
|
+
crate::provider::session_scan::pi::validate_exact_backing(
|
|
1453
|
+
path.as_path(),
|
|
1454
|
+
session_id,
|
|
1455
|
+
&spawn_cwd,
|
|
1456
|
+
)
|
|
1457
|
+
.is_ok()
|
|
1458
|
+
})
|
|
1459
|
+
}
|
|
1352
1460
|
Provider::GeminiCli | Provider::Fake => false,
|
|
1353
1461
|
};
|
|
1354
1462
|
|
|
@@ -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
|
+
}
|