@team-agent/installer 0.5.119 → 0.5.121
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/README.md +46 -1
- package/README.zh.md +46 -1
- package/crates/team-agent/src/cli/adapters.rs +11 -0
- package/crates/team-agent/src/cli/arguments.rs +401 -0
- package/crates/team-agent/src/cli/emit.rs +241 -142
- package/crates/team-agent/src/cli/intent/runtime.rs +1175 -0
- package/crates/team-agent/src/cli/intent/runtime_tests.rs +698 -0
- package/crates/team-agent/src/cli/intent/tests.rs +654 -0
- package/crates/team-agent/src/cli/intent.rs +642 -0
- package/crates/team-agent/src/cli/mod.rs +53 -6
- package/crates/team-agent/src/cli/named_address.rs +10 -1
- package/crates/team-agent/src/cli/spec.rs +22 -10
- package/crates/team-agent/src/cli/status_port/brief.rs +117 -16
- package/crates/team-agent/src/cli/tests/base.rs +6 -1
- package/crates/team-agent/src/cli/tests/parser_safety.rs +383 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +63 -19
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +56 -0
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +3 -2
- package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
- package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
- package/crates/team-agent/src/db/message_store.rs +65 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +8 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +111 -4
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +244 -82
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
- package/crates/team-agent/src/lifecycle/launch.rs +29 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +7 -1
- package/crates/team-agent/src/lifecycle/restart.rs +17 -0
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
- package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +64 -19
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +34 -10
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
- package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +47 -4
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
- package/crates/team-agent/src/lifecycle/types.rs +10 -0
- package/crates/team-agent/src/mcp_server/mod.rs +2 -0
- package/crates/team-agent/src/mcp_server/readiness.rs +432 -0
- package/crates/team-agent/src/mcp_server/readiness_tests.rs +242 -0
- package/crates/team-agent/src/mcp_server/wire.rs +96 -11
- package/crates/team-agent/src/messaging/delivery.rs +62 -0
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +41 -1
- package/crates/team-agent/src/provider/adapter.rs +7 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
- package/crates/team-agent/src/tmux_backend.rs +8 -1
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +71 -54
- package/skills/team-agent/command-coverage.json +283 -13
- package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
- package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
- package/skills/team-agent/references/recovery-runbook.md +61 -212
- package/skills/team-agent/references/team-agent-operator.md +181 -48
- package/skills/team-agent/references/team-in-team.md +47 -80
|
@@ -129,6 +129,14 @@ pub(super) fn running_agent_state(
|
|
|
129
129
|
if let Some(started_agent) = started_agent {
|
|
130
130
|
persist_started_agent_plan_state(&mut state, started_agent);
|
|
131
131
|
}
|
|
132
|
+
// Cursor chdirs to its native --workspace primary. Session archives are
|
|
133
|
+
// keyed by this directory, not the real project supplied via --add-dir.
|
|
134
|
+
let cursor_cwd = if provider == Provider::CursorAgent && cursor_mcp_isolation_enabled() {
|
|
135
|
+
Some(cursor_mcp_project_dir(workspace, id)?)
|
|
136
|
+
} else {
|
|
137
|
+
None
|
|
138
|
+
};
|
|
139
|
+
let spawn_cwd = cursor_cwd.as_deref().unwrap_or(spawn_cwd);
|
|
132
140
|
state.insert(
|
|
133
141
|
"spawn_cwd".to_string(),
|
|
134
142
|
serde_json::json!(spawn_cwd.to_string_lossy().to_string()),
|
|
@@ -33,6 +33,7 @@ use crate::provider::Provider;
|
|
|
33
33
|
|
|
34
34
|
use super::cursor_mcp_iso::{
|
|
35
35
|
cursor_mcp_isolation_enabled, cursor_mcp_project_dir, materialize_cursor_mcp_project,
|
|
36
|
+
validate_cursor_mcp_project_root,
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
/// Keys that must appear in mcp.json env. Cursor strips parent env down to
|
|
@@ -280,8 +281,31 @@ pub fn prepare_cursor_seat_mcp(
|
|
|
280
281
|
mcp_config: &crate::provider::McpConfig,
|
|
281
282
|
) -> Result<Option<PathBuf>, LifecycleError> {
|
|
282
283
|
if cursor_mcp_isolation_enabled() {
|
|
283
|
-
let
|
|
284
|
+
let server = mcp_config
|
|
285
|
+
.raw
|
|
286
|
+
.get("team_orchestrator")
|
|
287
|
+
.or_else(|| mcp_config.raw.get("team-agent"));
|
|
288
|
+
let env = server.and_then(|s| s.get("env"));
|
|
289
|
+
let field = |key| {
|
|
290
|
+
env.and_then(|v| v.get(key))
|
|
291
|
+
.and_then(serde_json::Value::as_str)
|
|
292
|
+
};
|
|
293
|
+
let physical = physical_workspace_path(workspace);
|
|
294
|
+
if field("TEAM_AGENT_ID") != Some(agent_id)
|
|
295
|
+
|| field("TEAM_AGENT_WORKSPACE") != physical.to_str()
|
|
296
|
+
{
|
|
297
|
+
return Err(LifecycleError::RequirementUnmet(
|
|
298
|
+
"cursor MCP resolved identity does not match the selected workspace/seat".into(),
|
|
299
|
+
));
|
|
300
|
+
}
|
|
301
|
+
let owner = field("TEAM_AGENT_OWNER_TEAM_ID")
|
|
302
|
+
.filter(|s| !s.trim().is_empty())
|
|
303
|
+
.ok_or_else(|| {
|
|
304
|
+
LifecycleError::RequirementUnmet("cursor MCP missing owner team".into())
|
|
305
|
+
})?;
|
|
306
|
+
let project = materialize_cursor_mcp_project(&physical, agent_id, owner)?;
|
|
284
307
|
apply_cursor_mcp_overlay(&project, mcp_config)?;
|
|
308
|
+
validate_cursor_project_mcp(&physical, &project)?;
|
|
285
309
|
Ok(Some(project))
|
|
286
310
|
} else {
|
|
287
311
|
apply_cursor_mcp_overlay(workspace, mcp_config)?;
|
|
@@ -305,14 +329,15 @@ pub fn enable_cursor_workspace_mcp_with_profile(
|
|
|
305
329
|
profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
|
|
306
330
|
) -> Result<(), LifecycleError> {
|
|
307
331
|
let physical = cursor_mcp_enable_working_dir(workspace, project_root);
|
|
332
|
+
if project_root.is_some() {
|
|
333
|
+
validate_cursor_project_mcp(&physical_workspace_path(workspace), &physical)?;
|
|
334
|
+
}
|
|
308
335
|
if skip_cursor_mcp_enable() {
|
|
309
336
|
return Ok(());
|
|
310
337
|
}
|
|
311
338
|
let argv = cursor_mcp_enable_argv();
|
|
312
339
|
let mut command = Command::new(&argv[0]);
|
|
313
|
-
command
|
|
314
|
-
.args(&argv[1..])
|
|
315
|
-
.current_dir(&physical);
|
|
340
|
+
command.args(&argv[1..]).current_dir(&physical);
|
|
316
341
|
apply_cursor_mcp_enable_profile_env(&mut command, profile_launch);
|
|
317
342
|
let output = command.output().map_err(|e| {
|
|
318
343
|
LifecycleError::RequirementUnmet(format!(
|
|
@@ -323,6 +348,11 @@ pub fn enable_cursor_workspace_mcp_with_profile(
|
|
|
323
348
|
))
|
|
324
349
|
})?;
|
|
325
350
|
if output.status.success() {
|
|
351
|
+
// Approval is not native tool readiness. Recheck our root/config after
|
|
352
|
+
// the external command; native loading/calls still need their own proof.
|
|
353
|
+
if project_root.is_some() {
|
|
354
|
+
validate_cursor_project_mcp(&physical_workspace_path(workspace), &physical)?;
|
|
355
|
+
}
|
|
326
356
|
return Ok(());
|
|
327
357
|
}
|
|
328
358
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
@@ -343,6 +373,83 @@ pub fn enable_cursor_workspace_mcp_with_profile(
|
|
|
343
373
|
)))
|
|
344
374
|
}
|
|
345
375
|
|
|
376
|
+
fn validate_cursor_project_mcp(workspace: &Path, project: &Path) -> Result<(), LifecycleError> {
|
|
377
|
+
use std::io::Read;
|
|
378
|
+
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
|
379
|
+
validate_cursor_mcp_project_root(project)?;
|
|
380
|
+
let refuse = || {
|
|
381
|
+
LifecycleError::RequirementUnmet(
|
|
382
|
+
"cursor MCP private config is missing, mismatched, or its server executable is unavailable; native tools are not ready".into(),
|
|
383
|
+
)
|
|
384
|
+
};
|
|
385
|
+
let file = std::fs::OpenOptions::new()
|
|
386
|
+
.read(true)
|
|
387
|
+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
|
388
|
+
.open(project.join(".cursor/mcp.json"))
|
|
389
|
+
.map_err(|_| refuse())?;
|
|
390
|
+
let metadata = file.metadata().map_err(|_| refuse())?;
|
|
391
|
+
if !metadata.is_file() || metadata.nlink() != 1 || metadata.uid() != unsafe { libc::geteuid() }
|
|
392
|
+
{
|
|
393
|
+
return Err(refuse());
|
|
394
|
+
}
|
|
395
|
+
let mut bytes = Vec::new();
|
|
396
|
+
file.take(65537)
|
|
397
|
+
.read_to_end(&mut bytes)
|
|
398
|
+
.map_err(|_| refuse())?;
|
|
399
|
+
if bytes.len() > 65536 {
|
|
400
|
+
return Err(refuse());
|
|
401
|
+
}
|
|
402
|
+
let config: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| refuse())?;
|
|
403
|
+
let server = config
|
|
404
|
+
.get("mcpServers")
|
|
405
|
+
.and_then(|v| v.get("team_orchestrator"))
|
|
406
|
+
.ok_or_else(refuse)?;
|
|
407
|
+
let env = server.get("env").ok_or_else(refuse)?;
|
|
408
|
+
for key in REQUIRED_IDENTITY_KEYS {
|
|
409
|
+
if env
|
|
410
|
+
.get(*key)
|
|
411
|
+
.and_then(serde_json::Value::as_str)
|
|
412
|
+
.is_none_or(|s| s.trim().is_empty() || s.contains('{'))
|
|
413
|
+
{
|
|
414
|
+
return Err(refuse());
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
let seat = env
|
|
418
|
+
.get("TEAM_AGENT_ID")
|
|
419
|
+
.and_then(serde_json::Value::as_str)
|
|
420
|
+
.ok_or_else(refuse)?;
|
|
421
|
+
let owner = env
|
|
422
|
+
.get("TEAM_AGENT_OWNER_TEAM_ID")
|
|
423
|
+
.and_then(serde_json::Value::as_str)
|
|
424
|
+
.ok_or_else(refuse)?;
|
|
425
|
+
if env
|
|
426
|
+
.get("TEAM_AGENT_WORKSPACE")
|
|
427
|
+
.and_then(serde_json::Value::as_str)
|
|
428
|
+
!= workspace.to_str()
|
|
429
|
+
|| cursor_mcp_project_dir(workspace, seat)? != project
|
|
430
|
+
|| materialize_cursor_mcp_project(workspace, seat, owner)? != project
|
|
431
|
+
{
|
|
432
|
+
return Err(refuse());
|
|
433
|
+
}
|
|
434
|
+
let command = server
|
|
435
|
+
.get("command")
|
|
436
|
+
.and_then(serde_json::Value::as_str)
|
|
437
|
+
.ok_or_else(refuse)?;
|
|
438
|
+
let metadata = std::fs::metadata(command).map_err(|_| refuse())?;
|
|
439
|
+
let current = std::env::current_exe()
|
|
440
|
+
.and_then(std::fs::canonicalize)
|
|
441
|
+
.map_err(|_| refuse())?;
|
|
442
|
+
if !Path::new(command).is_absolute()
|
|
443
|
+
|| !metadata.is_file()
|
|
444
|
+
|| metadata.mode() & 0o111 == 0
|
|
445
|
+
|| std::fs::canonicalize(command).map_err(|_| refuse())? != current
|
|
446
|
+
|| server.get("args") != Some(&serde_json::json!(["mcp-server", "--workspace", workspace]))
|
|
447
|
+
{
|
|
448
|
+
return Err(refuse());
|
|
449
|
+
}
|
|
450
|
+
Ok(())
|
|
451
|
+
}
|
|
452
|
+
|
|
346
453
|
pub(crate) fn apply_cursor_mcp_enable_profile_env(
|
|
347
454
|
command: &mut Command,
|
|
348
455
|
profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
|
|
@@ -1,120 +1,282 @@
|
|
|
1
|
-
//!
|
|
2
|
-
//!
|
|
3
|
-
//!
|
|
4
|
-
//!
|
|
5
|
-
//! - name: materialize_cursor_mcp_project
|
|
6
|
-
//! what: 在 provider-config/<id>/cursor 建 .cursor/,失败不写共用 mcp.json
|
|
7
|
-
//! - name: cursor_mcp_isolation_enabled
|
|
8
|
-
//! what: 默认开;TEAM_AGENT_CURSOR_MCP_ISOLATION=0 时关(只给破坏齿/回归旧闸)
|
|
9
|
-
//! - name: cursor_mcp_project_dir
|
|
10
|
-
//! what: 该席位作为 cursor --workspace 的工程根(未建盘)
|
|
11
|
-
//! boundary:
|
|
12
|
-
//! - 隔离失败 = RequirementUnmet,⛔ 不降级写 <workspace>/.cursor/mcp.json
|
|
13
|
-
//! - 不改 HOME(不是 copilot COPILOT_HOME / 会话库分叉)
|
|
14
|
-
//! - 不削弱 refuse_second:隔离关闭时仍拒第二席
|
|
15
|
-
//! maturity: wired
|
|
16
|
-
//! ---
|
|
1
|
+
//! Cursor's native MCP project root must be an owned primary outside Git ancestry.
|
|
2
|
+
//! Keep the real project as --add-dir; never overwrite its shared MCP config or
|
|
3
|
+
//! silently relocate an existing native session. The persisted directory receipt
|
|
4
|
+
//! is transport ownership, not a canonical Provider/native-session identity.
|
|
17
5
|
|
|
6
|
+
use std::io::{Read, Write};
|
|
7
|
+
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
|
|
18
8
|
use std::path::{Path, PathBuf};
|
|
19
9
|
|
|
10
|
+
use serde::{Deserialize, Serialize};
|
|
11
|
+
use team_agent_contract::contract::types::{
|
|
12
|
+
Generation, InstanceId, InstanceIdentity, ScopeId, SeatId,
|
|
13
|
+
};
|
|
14
|
+
use team_agent_contract::host::files::{DirectoryReceipt, ScopedDirectory};
|
|
15
|
+
use team_agent_contract::host::{digest, digest_hex};
|
|
16
|
+
|
|
20
17
|
use crate::lifecycle::LifecycleError;
|
|
21
18
|
|
|
22
19
|
const ISOLATION_ENV: &str = "TEAM_AGENT_CURSOR_MCP_ISOLATION";
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
#[derive(Serialize, Deserialize)]
|
|
22
|
+
#[serde(deny_unknown_fields)]
|
|
23
|
+
struct ProjectRecord {
|
|
24
|
+
workspace: PathBuf,
|
|
25
|
+
agent_id: String,
|
|
26
|
+
owner_team_id: String,
|
|
27
|
+
directory: DirectoryReceipt,
|
|
28
|
+
}
|
|
29
|
+
|
|
27
30
|
pub fn cursor_mcp_isolation_enabled() -> bool {
|
|
28
31
|
match std::env::var(ISOLATION_ENV) {
|
|
29
|
-
Ok(value) =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"0" | "false" | "FALSE" | "off" | "OFF" | "no" | "NO"
|
|
34
|
-
)
|
|
35
|
-
}
|
|
32
|
+
Ok(value) => !matches!(
|
|
33
|
+
value.trim(),
|
|
34
|
+
"0" | "false" | "FALSE" | "off" | "OFF" | "no" | "NO"
|
|
35
|
+
),
|
|
36
36
|
Err(_) => true,
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
fn failure(reason: impl std::fmt::Display) -> LifecycleError {
|
|
41
|
+
LifecycleError::RequirementUnmet(format!(
|
|
42
|
+
"cursor MCP private project refused: {reason}; do not overwrite shared .cursor/mcp.json or move an existing session"
|
|
43
|
+
))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn record_path(workspace: &Path, agent_id: &str) -> PathBuf {
|
|
47
|
+
workspace
|
|
48
|
+
.join(".team/runtime/provider-config")
|
|
49
|
+
.join(agent_id)
|
|
50
|
+
.join("cursor-project.json")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn project_key(workspace: &Path, agent_id: &str) -> String {
|
|
54
|
+
digest_hex(digest(
|
|
55
|
+
format!("{}\0{agent_id}", workspace.display()).as_bytes(),
|
|
56
|
+
))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn planned_project(workspace: &Path, agent_id: &str) -> Result<PathBuf, LifecycleError> {
|
|
60
|
+
// Do not assume TMPDIR is outside Git, or use an unchecked symlink alias.
|
|
61
|
+
let parent = std::env::temp_dir().canonicalize().map_err(failure)?;
|
|
62
|
+
validate_cursor_mcp_project_root(&parent)?;
|
|
63
|
+
Ok(parent.join(format!(
|
|
64
|
+
"ta-cursor-{}",
|
|
65
|
+
&project_key(workspace, agent_id)[..32]
|
|
66
|
+
)))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Computes a new path without creating it; existing allocations must reopen
|
|
70
|
+
/// their captured receipt, never adopt a replacement directory or a new TMPDIR.
|
|
43
71
|
pub fn cursor_mcp_project_dir(workspace: &Path, agent_id: &str) -> Result<PathBuf, LifecycleError> {
|
|
44
72
|
validate_agent_id(agent_id)?;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.join("cursor"))
|
|
73
|
+
let workspace = workspace.canonicalize().map_err(failure)?;
|
|
74
|
+
match read_record(&workspace, agent_id)? {
|
|
75
|
+
Some(record) => Ok(reopen(&record)?.path().to_path_buf()),
|
|
76
|
+
None => planned_project(&workspace, agent_id),
|
|
77
|
+
}
|
|
51
78
|
}
|
|
52
79
|
|
|
53
|
-
/// ---
|
|
54
|
-
/// purpose: 该席位 cursor 实际会读的项目 mcp.json
|
|
55
|
-
/// ---
|
|
56
80
|
pub fn cursor_mcp_json_path(workspace: &Path, agent_id: &str) -> Result<PathBuf, LifecycleError> {
|
|
57
|
-
Ok(cursor_mcp_project_dir(workspace, agent_id)
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
Ok(cursor_mcp_project_dir(workspace, agent_id)?.join(".cursor/mcp.json"))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Read-only scope proof for CLI planning. An external pathname or a matching
|
|
85
|
+
/// directory name is not ownership: the existing selected-team record must pin
|
|
86
|
+
/// this exact physical cwd. Never materialize or repair during preflight.
|
|
87
|
+
pub(crate) fn cursor_mcp_primary_matches_scope(
|
|
88
|
+
workspace: &Path,
|
|
89
|
+
owner_team_id: &str,
|
|
90
|
+
agent_id: &str,
|
|
91
|
+
declared_cwd: &Path,
|
|
92
|
+
) -> Result<bool, LifecycleError> {
|
|
93
|
+
validate_agent_id(agent_id)?;
|
|
94
|
+
let workspace = workspace.canonicalize().map_err(failure)?;
|
|
95
|
+
let Some(record) = read_record(&workspace, agent_id)? else {
|
|
96
|
+
return Ok(false);
|
|
97
|
+
};
|
|
98
|
+
if record.owner_team_id != owner_team_id || record.directory.path != declared_cwd {
|
|
99
|
+
return Ok(false);
|
|
100
|
+
}
|
|
101
|
+
Ok(reopen(&record)?.path() == declared_cwd)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Metadata-only mirror of the measured native project-root boundary. A .git
|
|
105
|
+
/// directory OR worktree file on any ancestor would redirect native MCP loading.
|
|
106
|
+
pub(super) fn validate_cursor_mcp_project_root(project: &Path) -> Result<(), LifecycleError> {
|
|
107
|
+
if project.canonicalize().map_err(failure)? != project {
|
|
108
|
+
return Err(failure("project path is not physical"));
|
|
109
|
+
}
|
|
110
|
+
for ancestor in project.ancestors() {
|
|
111
|
+
match std::fs::symlink_metadata(ancestor.join(".git")) {
|
|
112
|
+
Ok(_) => {
|
|
113
|
+
return Err(failure(
|
|
114
|
+
"Git ancestor would redirect native MCP projectRoot",
|
|
115
|
+
))
|
|
116
|
+
}
|
|
117
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
118
|
+
Err(e) => return Err(failure(e)),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
Ok(())
|
|
60
122
|
}
|
|
61
123
|
|
|
62
|
-
/// ---
|
|
63
|
-
/// purpose: 建 per-seat 工程根,并把团队 rules 链进该根(不链 mcp.json)
|
|
64
|
-
/// returns: 工程根
|
|
65
|
-
/// errors: agent_id 非法或建盘失败 → RequirementUnmet(不写共用 mcp.json)
|
|
66
|
-
/// ---
|
|
67
124
|
pub fn materialize_cursor_mcp_project(
|
|
68
125
|
workspace: &Path,
|
|
69
126
|
agent_id: &str,
|
|
127
|
+
owner_team_id: &str,
|
|
70
128
|
) -> Result<PathBuf, LifecycleError> {
|
|
71
|
-
|
|
72
|
-
let
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
))
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
129
|
+
validate_agent_id(agent_id)?;
|
|
130
|
+
let workspace = workspace.canonicalize().map_err(failure)?;
|
|
131
|
+
if owner_team_id.trim().is_empty() {
|
|
132
|
+
return Err(failure("missing owner team"));
|
|
133
|
+
}
|
|
134
|
+
if let Some(record) = read_record(&workspace, agent_id)? {
|
|
135
|
+
if record.owner_team_id != owner_team_id {
|
|
136
|
+
return Err(failure("record belongs to another owner team"));
|
|
137
|
+
}
|
|
138
|
+
return Ok(reopen(&record)?.path().to_path_buf());
|
|
139
|
+
}
|
|
140
|
+
let legacy = workspace
|
|
141
|
+
.join(".team/runtime/provider-config")
|
|
142
|
+
.join(agent_id)
|
|
143
|
+
.join("cursor");
|
|
144
|
+
match std::fs::symlink_metadata(&legacy) {
|
|
145
|
+
Ok(_) => return Err(failure("unrecorded legacy primary exists; preserve its cwd/SID and explicitly select a fresh seat")),
|
|
146
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
147
|
+
Err(e) => return Err(failure(e)),
|
|
148
|
+
}
|
|
149
|
+
let path = planned_project(&workspace, agent_id)?;
|
|
150
|
+
let key = project_key(&workspace, agent_id);
|
|
151
|
+
let mut nonce = [0u8; 32];
|
|
152
|
+
std::fs::File::open("/dev/urandom")
|
|
153
|
+
.and_then(|mut f| f.read_exact(&mut nonce))
|
|
154
|
+
.map_err(failure)?;
|
|
155
|
+
let owner = InstanceIdentity {
|
|
156
|
+
scope: ScopeId::new(format!("legacy-cursor-{}", &key[..32])).map_err(failure)?,
|
|
157
|
+
seat: SeatId::new(format!("seat-{}", &key[32..])).map_err(failure)?,
|
|
158
|
+
instance: InstanceId::new(format!("cwd-{}", digest_hex(digest(&nonce))))
|
|
159
|
+
.map_err(failure)?,
|
|
160
|
+
generation: Generation(1),
|
|
161
|
+
};
|
|
162
|
+
let directory = ScopedDirectory::create(&path, owner).map_err(failure)?;
|
|
163
|
+
std::fs::DirBuilder::new()
|
|
164
|
+
.mode(0o700)
|
|
165
|
+
.create(path.join(".cursor"))
|
|
166
|
+
.map_err(failure)?;
|
|
167
|
+
link_workspace_rules(&workspace, &path.join(".cursor"))?;
|
|
168
|
+
let record = ProjectRecord {
|
|
169
|
+
workspace: workspace.clone(),
|
|
170
|
+
agent_id: agent_id.into(),
|
|
171
|
+
owner_team_id: owner_team_id.into(),
|
|
172
|
+
directory: directory.receipt().clone(),
|
|
173
|
+
};
|
|
174
|
+
let record_path = record_path(&workspace, agent_id);
|
|
175
|
+
let parent = record_path
|
|
176
|
+
.parent()
|
|
177
|
+
.ok_or_else(|| failure("missing record parent"))?;
|
|
178
|
+
std::fs::create_dir_all(parent).map_err(failure)?;
|
|
179
|
+
if parent.canonicalize().map_err(failure)? != parent {
|
|
180
|
+
return Err(failure("record parent is a symlink"));
|
|
181
|
+
}
|
|
182
|
+
let bytes = serde_json::to_vec(&record).map_err(failure)?;
|
|
183
|
+
let mut file = std::fs::OpenOptions::new()
|
|
184
|
+
.write(true)
|
|
185
|
+
.create_new(true)
|
|
186
|
+
.mode(0o600)
|
|
187
|
+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
|
188
|
+
.open(record_path)
|
|
189
|
+
.map_err(failure)?;
|
|
190
|
+
file.write_all(&bytes)
|
|
191
|
+
.and_then(|()| file.sync_all())
|
|
192
|
+
.map_err(failure)?;
|
|
193
|
+
directory.check_live().map_err(failure)?;
|
|
194
|
+
validate_cursor_mcp_project_root(&path)?;
|
|
195
|
+
Ok(path)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn read_record(workspace: &Path, agent_id: &str) -> Result<Option<ProjectRecord>, LifecycleError> {
|
|
199
|
+
let path = record_path(workspace, agent_id);
|
|
200
|
+
let metadata = match std::fs::symlink_metadata(&path) {
|
|
201
|
+
Ok(metadata) => metadata,
|
|
202
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
203
|
+
Err(e) => return Err(failure(e)),
|
|
204
|
+
};
|
|
205
|
+
if !metadata.is_file()
|
|
206
|
+
|| metadata.file_type().is_symlink()
|
|
207
|
+
|| metadata.nlink() != 1
|
|
208
|
+
|| metadata.uid() != unsafe { libc::geteuid() }
|
|
209
|
+
|| metadata.mode() & 0o077 != 0
|
|
210
|
+
|| path.parent().and_then(|p| p.canonicalize().ok()).as_deref() != path.parent()
|
|
211
|
+
{
|
|
212
|
+
return Err(failure("unowned or aliased project record"));
|
|
213
|
+
}
|
|
214
|
+
let file = std::fs::OpenOptions::new()
|
|
215
|
+
.read(true)
|
|
216
|
+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
|
217
|
+
.open(&path)
|
|
218
|
+
.map_err(failure)?;
|
|
219
|
+
let opened = file.metadata().map_err(failure)?;
|
|
220
|
+
if opened.dev() != metadata.dev() || opened.ino() != metadata.ino() {
|
|
221
|
+
return Err(failure("project record replaced"));
|
|
222
|
+
}
|
|
223
|
+
let mut bytes = Vec::new();
|
|
224
|
+
file.take(65537).read_to_end(&mut bytes).map_err(failure)?;
|
|
225
|
+
if bytes.len() > 65536 {
|
|
226
|
+
return Err(failure("oversized project record"));
|
|
227
|
+
}
|
|
228
|
+
let record: ProjectRecord = serde_json::from_slice(&bytes).map_err(failure)?;
|
|
229
|
+
if record.workspace != workspace
|
|
230
|
+
|| record.agent_id != agent_id
|
|
231
|
+
|| record.owner_team_id.trim().is_empty()
|
|
232
|
+
{
|
|
233
|
+
return Err(failure("project record identity mismatch"));
|
|
234
|
+
}
|
|
235
|
+
Ok(Some(record))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn reopen(record: &ProjectRecord) -> Result<ScopedDirectory, LifecycleError> {
|
|
239
|
+
let key = project_key(&record.workspace, &record.agent_id);
|
|
240
|
+
if record.directory.path.file_name().and_then(|n| n.to_str())
|
|
241
|
+
!= Some(format!("ta-cursor-{}", &key[..32]).as_str())
|
|
242
|
+
|| record.directory.owner.scope.as_str() != format!("legacy-cursor-{}", &key[..32])
|
|
243
|
+
|| record.directory.owner.seat.as_str() != format!("seat-{}", &key[32..])
|
|
244
|
+
|| record.directory.owner.generation != Generation(1)
|
|
245
|
+
{
|
|
246
|
+
return Err(failure("directory receipt identity mismatch"));
|
|
247
|
+
}
|
|
248
|
+
let directory = ScopedDirectory::reopen(record.directory.clone()).map_err(failure)?;
|
|
249
|
+
validate_cursor_mcp_project_root(directory.path())?;
|
|
250
|
+
let cursor = directory.path().join(".cursor");
|
|
251
|
+
let metadata = std::fs::symlink_metadata(&cursor).map_err(failure)?;
|
|
252
|
+
if !metadata.is_dir()
|
|
253
|
+
|| metadata.file_type().is_symlink()
|
|
254
|
+
|| metadata.uid() != unsafe { libc::geteuid() }
|
|
255
|
+
|| metadata.mode() & 0o077 != 0
|
|
256
|
+
{
|
|
257
|
+
return Err(failure("unowned .cursor directory"));
|
|
258
|
+
}
|
|
259
|
+
Ok(directory)
|
|
84
260
|
}
|
|
85
261
|
|
|
86
262
|
fn validate_agent_id(agent_id: &str) -> Result<(), LifecycleError> {
|
|
87
263
|
if agent_id.is_empty()
|
|
88
|
-
|| agent_id.contains('/')
|
|
89
|
-
|| agent_id.contains('\\')
|
|
90
264
|
|| agent_id.contains("..")
|
|
91
265
|
|| !agent_id
|
|
92
266
|
.chars()
|
|
93
|
-
.all(|c| c.is_ascii_alphanumeric() || c
|
|
267
|
+
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
|
94
268
|
{
|
|
95
|
-
return Err(
|
|
96
|
-
"
|
|
97
|
-
|
|
98
|
-
action: rename the seat; do not share <workspace>/.cursor/mcp.json"
|
|
99
|
-
)));
|
|
269
|
+
return Err(failure(
|
|
270
|
+
"agent id must be a single path segment [A-Za-z0-9._-]+",
|
|
271
|
+
));
|
|
100
272
|
}
|
|
101
273
|
Ok(())
|
|
102
274
|
}
|
|
103
275
|
|
|
104
276
|
fn link_workspace_rules(workspace: &Path, dest_cursor: &Path) -> Result<(), LifecycleError> {
|
|
105
|
-
let source = workspace.join(".cursor
|
|
106
|
-
if
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if dest.exists() {
|
|
111
|
-
return Ok(());
|
|
112
|
-
}
|
|
113
|
-
std::os::unix::fs::symlink(&source, &dest).map_err(|e| {
|
|
114
|
-
LifecycleError::RequirementUnmet(format!(
|
|
115
|
-
"error: cannot link workspace cursor rules into per-seat project\n\
|
|
116
|
-
reason: {e}\n\
|
|
117
|
-
action: do not fall back to <workspace>/.cursor/mcp.json"
|
|
118
|
-
))
|
|
119
|
-
})
|
|
277
|
+
let source = workspace.join(".cursor/rules");
|
|
278
|
+
if source.exists() {
|
|
279
|
+
std::os::unix::fs::symlink(source, dest_cursor.join("rules")).map_err(failure)?;
|
|
280
|
+
}
|
|
281
|
+
Ok(())
|
|
120
282
|
}
|