@team-agent/installer 0.5.77 → 0.5.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +161 -29
- package/crates/team-agent/src/cli/diagnose.rs +333 -68
- package/crates/team-agent/src/cli/emit.rs +24 -8
- package/crates/team-agent/src/cli/mod.rs +543 -98
- package/crates/team-agent/src/cli/models.rs +393 -32
- package/crates/team-agent/src/cli/named_address.rs +19 -5
- package/crates/team-agent/src/cli/profile.rs +35 -4
- package/crates/team-agent/src/cli/spec.rs +7 -2
- package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
- package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
- package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
- package/crates/team-agent/src/cli/status_port/store.rs +1 -1
- package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
- package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
- package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
- package/crates/team-agent/src/leader/lease.rs +492 -33
- package/crates/team-agent/src/leader/owner_bind.rs +40 -12
- package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
- package/crates/team-agent/src/leader/rediscover.rs +36 -6
- package/crates/team-agent/src/leader/start.rs +197 -88
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
- package/crates/team-agent/src/lifecycle/launch.rs +30 -7
- package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
- package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
- package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
- package/crates/team-agent/src/lifecycle/restart.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
- package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +140 -11
- package/crates/team-agent/src/messaging/delivery.rs +221 -80
- package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
- package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
- package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
- package/crates/team-agent/src/state/mod.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +102 -4
- package/crates/team-agent/src/state/projection.rs +7 -1
- package/crates/team-agent/src/state/repository.rs +29 -0
- package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
- package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
- package/crates/team-agent/src/tmux_backend.rs +239 -127
- package/crates/team-agent/src/transport.rs +78 -0
- package/crates/team-agent/src/transport_factory.rs +53 -0
- package/package.json +4 -5
- package/skills/team-agent/SKILL.md +45 -17
- package/skills/team-agent/command-coverage.json +4 -29
- package/npm/check-skill-links.mjs +0 -88
- package/skills/team-agent/references/team-agent-operator.md +0 -672
|
@@ -3,21 +3,21 @@
|
|
|
3
3
|
//! contract:
|
|
4
4
|
//! provides:
|
|
5
5
|
//! - name: apply_cursor_mcp_overlay
|
|
6
|
-
//! what:
|
|
7
|
-
//! - name: apply_cursor_spawn_workspace_pointers
|
|
8
|
-
//! what: 隔离开时 --workspace 指 per-seat 工程根,--add-dir 指真 workspace
|
|
6
|
+
//! what: 写 <workspace>/.cursor/mcp.json,env 必须带 TEAM_AGENT_ID
|
|
9
7
|
//! - name: cursor_mcp_enable_argv
|
|
10
8
|
//! what: 组 `agent mcp enable team_orchestrator`(不认 --workspace)
|
|
11
9
|
//! - name: enable_cursor_workspace_mcp
|
|
12
|
-
//! what:
|
|
10
|
+
//! what: 在工程根(可选)或物理工作目录跑 enable;测试隔离下跳过以免写 ~/.cursor
|
|
11
|
+
//! - name: apply_cursor_spawn_workspace_pointers
|
|
12
|
+
//! what: 隔离开时 --workspace 指 per-seat 工程根,--add-dir 指真 workspace
|
|
13
13
|
//! - name: physical_workspace_path
|
|
14
14
|
//! what: pwd -P 等价路径;mcp enable 按 getcwd 分片
|
|
15
15
|
//! - name: refuse_second_cursor_occupant
|
|
16
|
-
//! what:
|
|
16
|
+
//! what: 同一物理 workspace 第二 CursorAgent 拒绝(mcp.json last-writer)
|
|
17
17
|
//! boundary:
|
|
18
|
-
//! -
|
|
18
|
+
//! - 同 workspace 第二 CursorAgent 拒绝;U-07 过前不装「已支持多席」
|
|
19
19
|
//! - 不把身份从 json env 删掉(cursor 不继承父进程 TEAM_AGENT_*)
|
|
20
|
-
//! -
|
|
20
|
+
//! - 不写 ~/.cursor/mcp.json 全局文件
|
|
21
21
|
//! - 不调 --approve-mcps(未验证能代替 enable)
|
|
22
22
|
//! maturity: wired
|
|
23
23
|
//! ---
|
|
@@ -26,8 +26,6 @@ use std::collections::BTreeMap;
|
|
|
26
26
|
use std::path::{Path, PathBuf};
|
|
27
27
|
use std::process::Command;
|
|
28
28
|
|
|
29
|
-
use sha2::{Digest, Sha256};
|
|
30
|
-
|
|
31
29
|
use crate::lifecycle::LifecycleError;
|
|
32
30
|
use crate::model::yaml::Value as YamlValue;
|
|
33
31
|
use crate::provider::wire::command_name;
|
|
@@ -46,123 +44,6 @@ const REQUIRED_IDENTITY_KEYS: &[&str] = &[
|
|
|
46
44
|
"TEAM_AGENT_AUTH_MODE",
|
|
47
45
|
];
|
|
48
46
|
|
|
49
|
-
const CURSOR_ENABLE_STDERR_MAX_BYTES: usize = 512;
|
|
50
|
-
const CURSOR_ENABLE_SECRET_INDICATORS: &[&str] = &[
|
|
51
|
-
"authorization",
|
|
52
|
-
"bearer",
|
|
53
|
-
"api_key",
|
|
54
|
-
"cookie",
|
|
55
|
-
"password",
|
|
56
|
-
"token=",
|
|
57
|
-
"sk-",
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
impl LifecycleError {
|
|
61
|
-
pub(crate) fn cursor_mcp_enable_failure(
|
|
62
|
-
argv: &[String],
|
|
63
|
-
physical_cwd: &Path,
|
|
64
|
-
exit_code: Option<i32>,
|
|
65
|
-
stdout: &[u8],
|
|
66
|
-
stderr: &[u8],
|
|
67
|
-
spawn_error: Option<&str>,
|
|
68
|
-
) -> Self {
|
|
69
|
-
let config_path = physical_cwd.join(".cursor").join("mcp.json");
|
|
70
|
-
let metadata = std::fs::metadata(&config_path).ok();
|
|
71
|
-
let config_exists = metadata.is_some();
|
|
72
|
-
let config_size = metadata
|
|
73
|
-
.as_ref()
|
|
74
|
-
.map(|value| value.len().to_string())
|
|
75
|
-
.unwrap_or_else(|| "unavailable".to_string());
|
|
76
|
-
#[cfg(unix)]
|
|
77
|
-
let config_mode = {
|
|
78
|
-
use std::os::unix::fs::PermissionsExt;
|
|
79
|
-
metadata
|
|
80
|
-
.as_ref()
|
|
81
|
-
.map(|value| format!("0o{:o}", value.permissions().mode()))
|
|
82
|
-
.unwrap_or_else(|| "unavailable".to_string())
|
|
83
|
-
};
|
|
84
|
-
#[cfg(not(unix))]
|
|
85
|
-
let config_mode = "unavailable_non_unix".to_string();
|
|
86
|
-
let config_sha256 = std::fs::read(&config_path)
|
|
87
|
-
.ok()
|
|
88
|
-
.map(|body| format!("{:x}", Sha256::digest(body)))
|
|
89
|
-
.unwrap_or_else(|| "unavailable".to_string());
|
|
90
|
-
let stderr_fact = bounded_safe_cursor_stderr(stderr)
|
|
91
|
-
.map(|safe| format!("stderr_first_safe: {safe}"))
|
|
92
|
-
.unwrap_or_else(|| "stderr_redacted".to_string());
|
|
93
|
-
let (reason, action) = match spawn_error {
|
|
94
|
-
Some(error) => (
|
|
95
|
-
format!("cannot run cursor MCP enable: {error}"),
|
|
96
|
-
format!(
|
|
97
|
-
"install cursor-agent on PATH (same binary as `{}`) and retry",
|
|
98
|
-
argv[0]
|
|
99
|
-
),
|
|
100
|
-
),
|
|
101
|
-
None => (
|
|
102
|
-
"without enable, cursor keeps MCP as not loaded (needs approval)".to_string(),
|
|
103
|
-
format!(
|
|
104
|
-
"from that directory run `{} mcp enable team_orchestrator`",
|
|
105
|
-
argv[0]
|
|
106
|
-
),
|
|
107
|
-
),
|
|
108
|
-
};
|
|
109
|
-
LifecycleError::RequirementUnmet(format!(
|
|
110
|
-
"error: `{}` failed (exit {})\n\
|
|
111
|
-
reason: {reason}\n\
|
|
112
|
-
argv: {}\n\
|
|
113
|
-
cwd: {}\n\
|
|
114
|
-
config_path: {}\n\
|
|
115
|
-
config_exists: {config_exists}\n\
|
|
116
|
-
config_mode: {config_mode}\n\
|
|
117
|
-
config_size: {config_size}\n\
|
|
118
|
-
config_sha256: {config_sha256}\n\
|
|
119
|
-
stdout_len: {}\n\
|
|
120
|
-
stderr_len: {}\n\
|
|
121
|
-
{stderr_fact}\n\
|
|
122
|
-
action: {action}",
|
|
123
|
-
argv.join(" "),
|
|
124
|
-
exit_code
|
|
125
|
-
.map(|code| code.to_string())
|
|
126
|
-
.unwrap_or_else(|| "unavailable".to_string()),
|
|
127
|
-
argv.join(" "),
|
|
128
|
-
physical_cwd.display(),
|
|
129
|
-
config_path.display(),
|
|
130
|
-
stdout.len(),
|
|
131
|
-
stderr.len(),
|
|
132
|
-
))
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
fn bounded_safe_cursor_stderr(stderr: &[u8]) -> Option<String> {
|
|
137
|
-
let stderr = String::from_utf8_lossy(stderr);
|
|
138
|
-
let mut safe = String::new();
|
|
139
|
-
'lines: for line in stderr.lines() {
|
|
140
|
-
let lower = line.to_ascii_lowercase();
|
|
141
|
-
if CURSOR_ENABLE_SECRET_INDICATORS
|
|
142
|
-
.iter()
|
|
143
|
-
.any(|indicator| lower.contains(indicator))
|
|
144
|
-
{
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if line.trim().is_empty() {
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
if !safe.is_empty() {
|
|
151
|
-
if safe.len() == CURSOR_ENABLE_STDERR_MAX_BYTES {
|
|
152
|
-
break;
|
|
153
|
-
}
|
|
154
|
-
safe.push('\n');
|
|
155
|
-
}
|
|
156
|
-
for character in line.chars() {
|
|
157
|
-
if safe.len() + character.len_utf8() > CURSOR_ENABLE_STDERR_MAX_BYTES {
|
|
158
|
-
break 'lines;
|
|
159
|
-
}
|
|
160
|
-
safe.push(character);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
(!safe.is_empty()).then_some(safe)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
47
|
/// ---
|
|
167
48
|
/// purpose: 给出 workspace 的物理路径
|
|
168
49
|
/// returns: 能 canonicalize 就用它,否则原样返回
|
|
@@ -186,18 +67,17 @@ fn agent_is_cursor(provider: Option<&str>) -> bool {
|
|
|
186
67
|
}
|
|
187
68
|
|
|
188
69
|
/// ---
|
|
189
|
-
/// purpose: 同一物理 workspace
|
|
70
|
+
/// purpose: 同一物理 workspace 拒绝第二 CursorAgent
|
|
190
71
|
/// params:
|
|
191
72
|
/// workspace: 物理工作目录
|
|
192
73
|
/// incoming_id: 正要起的席位 id
|
|
193
74
|
/// spec: 可选 yaml spec,用来在 state 尚未写入时看见第二席
|
|
194
|
-
/// returns:
|
|
75
|
+
/// returns: 已有其它 CursorAgent 则 RequirementUnmet,文案含 TEAM_AGENT_ID 与 mcp.json
|
|
195
76
|
/// contract:
|
|
196
77
|
/// provides:
|
|
197
78
|
/// - name: refuse_second_cursor_occupant
|
|
198
|
-
/// what:
|
|
79
|
+
/// what: mcp.json last-writer 闸;U-07 过前不装多席
|
|
199
80
|
/// boundary:
|
|
200
|
-
/// - 隔离失败/关闭时不改文案语义(仍点名 mcp.json last-writer)
|
|
201
81
|
/// - 不改 grok 独占实现
|
|
202
82
|
/// ---
|
|
203
83
|
pub fn refuse_second_cursor_occupant(
|
|
@@ -249,9 +129,6 @@ pub fn refuse_second_cursor_occupant(
|
|
|
249
129
|
if others.is_empty() {
|
|
250
130
|
return Ok(());
|
|
251
131
|
}
|
|
252
|
-
if cursor_mcp_isolation_enabled() {
|
|
253
|
-
return Ok(());
|
|
254
|
-
}
|
|
255
132
|
Err(LifecycleError::RequirementUnmet(format!(
|
|
256
133
|
"error: cursor_agent seat already occupies this workspace\n\
|
|
257
134
|
incoming: {incoming_id}\n\
|
|
@@ -269,41 +146,16 @@ fn agent_is_paused_yaml(agent: &YamlValue) -> bool {
|
|
|
269
146
|
}
|
|
270
147
|
|
|
271
148
|
/// ---
|
|
272
|
-
/// purpose: 写 cursor 会读的 mcp.json
|
|
149
|
+
/// purpose: 写 workspace 下 cursor 会读的 mcp.json,env 里必须带席位身份键
|
|
273
150
|
/// params:
|
|
274
151
|
/// mcp_config: 已解析的 MCP 配置,须含 team_orchestrator 与 command
|
|
275
152
|
/// returns: 写出的文件路径
|
|
276
|
-
/// errors: 缺条目或缺 command 时返回 StatePersist
|
|
153
|
+
/// errors: 缺条目或缺 command 时返回 StatePersist,写盘失败也返回 StatePersist
|
|
277
154
|
/// ---
|
|
278
155
|
pub fn apply_cursor_mcp_overlay(
|
|
279
156
|
workspace: &Path,
|
|
280
157
|
mcp_config: &crate::provider::McpConfig,
|
|
281
158
|
) -> Result<PathBuf, LifecycleError> {
|
|
282
|
-
let (command, args, env) = parse_orchestrator_env(mcp_config)?;
|
|
283
|
-
let agent_id = env
|
|
284
|
-
.get("TEAM_AGENT_ID")
|
|
285
|
-
.map(String::as_str)
|
|
286
|
-
.filter(|value| !value.trim().is_empty())
|
|
287
|
-
.ok_or_else(|| {
|
|
288
|
-
LifecycleError::StatePersist(
|
|
289
|
-
"cursor MCP overlay missing resolved TEAM_AGENT_ID in json env \
|
|
290
|
-
(cursor does not inherit parent TEAM_AGENT_* into the MCP child)"
|
|
291
|
-
.to_string(),
|
|
292
|
-
)
|
|
293
|
-
})?;
|
|
294
|
-
let cursor_dir = if cursor_mcp_isolation_enabled() {
|
|
295
|
-
let project = materialize_cursor_mcp_project(workspace, agent_id)?;
|
|
296
|
-
scrub_workspace_orchestrator_mcp(workspace)?;
|
|
297
|
-
project.join(".cursor")
|
|
298
|
-
} else {
|
|
299
|
-
workspace.join(".cursor")
|
|
300
|
-
};
|
|
301
|
-
write_cursor_mcp_json_at(&cursor_dir, command, args, env)
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
fn parse_orchestrator_env(
|
|
305
|
-
mcp_config: &crate::provider::McpConfig,
|
|
306
|
-
) -> Result<(String, Vec<String>, BTreeMap<String, String>), LifecycleError> {
|
|
307
159
|
let server = mcp_config
|
|
308
160
|
.raw
|
|
309
161
|
.get("team_orchestrator")
|
|
@@ -319,8 +171,7 @@ fn parse_orchestrator_env(
|
|
|
319
171
|
.filter(|value| !value.is_empty())
|
|
320
172
|
.ok_or_else(|| {
|
|
321
173
|
LifecycleError::StatePersist("cursor MCP overlay missing command".to_string())
|
|
322
|
-
})
|
|
323
|
-
.to_string();
|
|
174
|
+
})?;
|
|
324
175
|
let args = server
|
|
325
176
|
.get("args")
|
|
326
177
|
.and_then(serde_json::Value::as_array)
|
|
@@ -354,16 +205,9 @@ fn parse_orchestrator_env(
|
|
|
354
205
|
}
|
|
355
206
|
}
|
|
356
207
|
}
|
|
357
|
-
Ok((command, args, env))
|
|
358
|
-
}
|
|
359
208
|
|
|
360
|
-
|
|
361
|
-
dir
|
|
362
|
-
command: String,
|
|
363
|
-
args: Vec<String>,
|
|
364
|
-
env: BTreeMap<String, String>,
|
|
365
|
-
) -> Result<PathBuf, LifecycleError> {
|
|
366
|
-
std::fs::create_dir_all(dir)
|
|
209
|
+
let dir = workspace.join(".cursor");
|
|
210
|
+
std::fs::create_dir_all(&dir)
|
|
367
211
|
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", dir.display())))?;
|
|
368
212
|
let path = dir.join("mcp.json");
|
|
369
213
|
let mut root = read_existing_mcp_json(&path);
|
|
@@ -405,28 +249,6 @@ fn write_cursor_mcp_json_at(
|
|
|
405
249
|
Ok(path)
|
|
406
250
|
}
|
|
407
251
|
|
|
408
|
-
fn scrub_workspace_orchestrator_mcp(workspace: &Path) -> Result<(), LifecycleError> {
|
|
409
|
-
let path = workspace.join(".cursor").join("mcp.json");
|
|
410
|
-
if !path.is_file() {
|
|
411
|
-
return Ok(());
|
|
412
|
-
}
|
|
413
|
-
let mut root = read_existing_mcp_json(&path);
|
|
414
|
-
let Some(servers) = root
|
|
415
|
-
.as_object_mut()
|
|
416
|
-
.and_then(|obj| obj.get_mut("mcpServers"))
|
|
417
|
-
.and_then(serde_json::Value::as_object_mut)
|
|
418
|
-
else {
|
|
419
|
-
return Ok(());
|
|
420
|
-
};
|
|
421
|
-
servers.remove("team_orchestrator");
|
|
422
|
-
servers.remove("team-agent");
|
|
423
|
-
let body = serde_json::to_string_pretty(&root)
|
|
424
|
-
.map_err(|e| LifecycleError::StatePersist(format!("serialize cursor mcp.json: {e}")))?;
|
|
425
|
-
std::fs::write(&path, body.as_bytes())
|
|
426
|
-
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
|
|
427
|
-
Ok(())
|
|
428
|
-
}
|
|
429
|
-
|
|
430
252
|
/// ---
|
|
431
253
|
/// purpose: 组出启用 team_orchestrator 的 cursor 命令行
|
|
432
254
|
/// returns: 不带 workspace 参数的 argv,该命令按进程工作目录分片
|
|
@@ -441,49 +263,99 @@ pub fn cursor_mcp_enable_argv() -> Vec<String> {
|
|
|
441
263
|
}
|
|
442
264
|
|
|
443
265
|
/// ---
|
|
444
|
-
/// purpose:
|
|
266
|
+
/// purpose: 在物理工作目录下执行 cursor 的 MCP 启用命令
|
|
445
267
|
/// returns: 测试隔离环境或显式跳过标志下直接成功,避免写用户全局配置
|
|
446
|
-
/// errors: 命令跑不起来或退出码非零时返回 RequirementUnmet
|
|
268
|
+
/// errors: 命令跑不起来或退出码非零时返回 RequirementUnmet,错误里只记输出长度不记内容
|
|
447
269
|
/// ---
|
|
270
|
+
pub fn cursor_mcp_enable_working_dir(workspace: &Path, project_root: Option<&Path>) -> PathBuf {
|
|
271
|
+
match project_root {
|
|
272
|
+
Some(root) => physical_workspace_path(root),
|
|
273
|
+
None => physical_workspace_path(workspace),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
pub fn prepare_cursor_seat_mcp(
|
|
278
|
+
workspace: &Path,
|
|
279
|
+
agent_id: &str,
|
|
280
|
+
mcp_config: &crate::provider::McpConfig,
|
|
281
|
+
) -> Result<Option<PathBuf>, LifecycleError> {
|
|
282
|
+
if cursor_mcp_isolation_enabled() {
|
|
283
|
+
let project = materialize_cursor_mcp_project(workspace, agent_id)?;
|
|
284
|
+
apply_cursor_mcp_overlay(&project, mcp_config)?;
|
|
285
|
+
Ok(Some(project))
|
|
286
|
+
} else {
|
|
287
|
+
apply_cursor_mcp_overlay(workspace, mcp_config)?;
|
|
288
|
+
Ok(None)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
448
292
|
pub fn enable_cursor_workspace_mcp(
|
|
449
293
|
workspace: &Path,
|
|
450
294
|
project_root: Option<&Path>,
|
|
451
295
|
) -> Result<(), LifecycleError> {
|
|
296
|
+
enable_cursor_workspace_mcp_with_profile(workspace, project_root, None)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/// Run Cursor's MCP enable command with the same profile-local environment
|
|
300
|
+
/// policy as the worker spawn. In particular, subscription direct mode must
|
|
301
|
+
/// not reintroduce proxy URL keys during this preparatory command.
|
|
302
|
+
pub fn enable_cursor_workspace_mcp_with_profile(
|
|
303
|
+
workspace: &Path,
|
|
304
|
+
project_root: Option<&Path>,
|
|
305
|
+
profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
|
|
306
|
+
) -> Result<(), LifecycleError> {
|
|
307
|
+
let physical = cursor_mcp_enable_working_dir(workspace, project_root);
|
|
452
308
|
if skip_cursor_mcp_enable() {
|
|
453
309
|
return Ok(());
|
|
454
310
|
}
|
|
455
|
-
let physical = match project_root {
|
|
456
|
-
Some(root) => physical_workspace_path(root),
|
|
457
|
-
None => physical_workspace_path(workspace),
|
|
458
|
-
};
|
|
459
311
|
let argv = cursor_mcp_enable_argv();
|
|
460
|
-
let mut
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
));
|
|
474
|
-
}
|
|
475
|
-
};
|
|
312
|
+
let mut command = Command::new(&argv[0]);
|
|
313
|
+
command
|
|
314
|
+
.args(&argv[1..])
|
|
315
|
+
.current_dir(&physical);
|
|
316
|
+
apply_cursor_mcp_enable_profile_env(&mut command, profile_launch);
|
|
317
|
+
let output = command.output().map_err(|e| {
|
|
318
|
+
LifecycleError::RequirementUnmet(format!(
|
|
319
|
+
"error: cannot run `{} mcp enable team_orchestrator`\n\
|
|
320
|
+
reason: {e}\n\
|
|
321
|
+
action: install cursor-agent on PATH (same binary as `agent`) and retry",
|
|
322
|
+
argv[0]
|
|
323
|
+
))
|
|
324
|
+
})?;
|
|
476
325
|
if output.status.success() {
|
|
477
326
|
return Ok(());
|
|
478
327
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
328
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
329
|
+
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
330
|
+
Err(LifecycleError::RequirementUnmet(format!(
|
|
331
|
+
"error: `{} mcp enable team_orchestrator` failed (exit {:?})\n\
|
|
332
|
+
reason: without enable, cursor keeps MCP as not loaded (needs approval)\n\
|
|
333
|
+
cwd: {}\n\
|
|
334
|
+
stdout_len: {}\n\
|
|
335
|
+
stderr_len: {}\n\
|
|
336
|
+
action: from that directory run `{} mcp enable team_orchestrator`",
|
|
337
|
+
argv[0],
|
|
482
338
|
output.status.code(),
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
339
|
+
physical.display(),
|
|
340
|
+
stdout.len(),
|
|
341
|
+
stderr.len(),
|
|
342
|
+
argv[0]
|
|
343
|
+
)))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
pub(crate) fn apply_cursor_mcp_enable_profile_env(
|
|
347
|
+
command: &mut Command,
|
|
348
|
+
profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
|
|
349
|
+
) {
|
|
350
|
+
let Some(profile_launch) = profile_launch else {
|
|
351
|
+
return;
|
|
352
|
+
};
|
|
353
|
+
for key in &profile_launch.env_unset {
|
|
354
|
+
command.env_remove(key);
|
|
355
|
+
}
|
|
356
|
+
for (key, value) in &profile_launch.env_overlay {
|
|
357
|
+
command.env(key, value);
|
|
358
|
+
}
|
|
487
359
|
}
|
|
488
360
|
|
|
489
361
|
/// ---
|
|
@@ -503,11 +375,6 @@ pub fn apply_cursor_workspace_physical_path(argv: &mut [String], workspace: &Pat
|
|
|
503
375
|
|
|
504
376
|
/// ---
|
|
505
377
|
/// purpose: 隔离开时把 cursor --workspace 指到 per-seat 工程根,并用 --add-dir 挂上真 workspace
|
|
506
|
-
/// params:
|
|
507
|
-
/// argv: 就地改写(可能追加 --add-dir)
|
|
508
|
-
/// workspace: 团队工作目录
|
|
509
|
-
/// agent_id: 席位 id
|
|
510
|
-
/// errors: 工程根路径非法时返回 RequirementUnmet
|
|
511
378
|
/// ---
|
|
512
379
|
pub fn apply_cursor_spawn_workspace_pointers(
|
|
513
380
|
argv: &mut Vec<String>,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//! - name: reconcile_grok_toml_per_seat_keys
|
|
12
12
|
//! what: 起 grok 席前清掉共享 toml 里遗留的 per-seat 键并留审计事件
|
|
13
13
|
//! - name: ensure_grok_login_and_folder_trust
|
|
14
|
-
//! what: grok
|
|
14
|
+
//! what: grok 未登录或目录未信任时拒绝起席
|
|
15
15
|
//! - name: point_native_mcp_config_at_file
|
|
16
16
|
//! what: 把 argv 里的 MCP 配置参数改指到已落盘的文件
|
|
17
17
|
//! depends:
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
//! ---
|
|
30
30
|
use std::collections::{BTreeMap, BTreeSet};
|
|
31
31
|
use std::path::{Path, PathBuf};
|
|
32
|
+
use std::process::Command;
|
|
32
33
|
|
|
33
34
|
use crate::lifecycle::*;
|
|
34
35
|
use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
|
|
@@ -380,19 +381,20 @@ fn grok_occupied_cwd_error(
|
|
|
380
381
|
}
|
|
381
382
|
|
|
382
383
|
/// ---
|
|
383
|
-
/// purpose: 起 grok
|
|
384
|
+
/// purpose: 起 grok 席前确认已登录且该目录已被信任
|
|
384
385
|
/// params:
|
|
385
|
-
///
|
|
386
|
-
/// returns:
|
|
387
|
-
/// errors: HOME
|
|
386
|
+
/// cwd: 席位的工作目录,用于判断目录信任
|
|
387
|
+
/// returns: 通过返回空值;关闭 folder-trust 的环境变量下跳过信任检查
|
|
388
|
+
/// errors: HOME 未设、登录态文件缺失或为空、目录未被信任时返回 RequirementUnmet
|
|
388
389
|
/// ---
|
|
389
|
-
///
|
|
390
|
-
/// `$HOME/.grok/auth.json
|
|
391
|
-
///
|
|
392
|
-
|
|
390
|
+
/// 未登录 / 目录未信任时不许起出「能收信、没有手」的 grok 席。
|
|
391
|
+
/// 登录态看 `$HOME/.grok/auth.json`;目录信任看 `$HOME/.grok/trusted_folders.toml`
|
|
392
|
+
/// (与 grok `--trust` / `/hooks-trust` 同一份;未信任则项目作用域 MCP 不生效)。
|
|
393
|
+
/// `GROK_FOLDER_TRUST=0` 时 grok 自己关掉 folder-trust,本检查跟着放行。
|
|
394
|
+
pub(crate) fn ensure_grok_login_and_folder_trust(cwd: &Path) -> Result<(), LifecycleError> {
|
|
393
395
|
let home = std::env::var_os("HOME").map(PathBuf::from).ok_or_else(|| {
|
|
394
396
|
LifecycleError::RequirementUnmet(
|
|
395
|
-
"error: HOME is unset; cannot verify grok login\n\
|
|
397
|
+
"error: HOME is unset; cannot verify grok login or folder trust\n\
|
|
396
398
|
action: export HOME and retry"
|
|
397
399
|
.to_string(),
|
|
398
400
|
)
|
|
@@ -405,6 +407,15 @@ pub(crate) fn ensure_grok_login_and_folder_trust(_cwd: &Path) -> Result<(), Life
|
|
|
405
407
|
grok_home.join("auth.json").display()
|
|
406
408
|
)));
|
|
407
409
|
}
|
|
410
|
+
if folder_trust_required() && !grok_folder_is_trusted(&grok_home, cwd) {
|
|
411
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
412
|
+
"error: grok folder is not trusted; project-scope MCP at {}/.grok/config.toml will not load\n\
|
|
413
|
+
cwd: {}\n\
|
|
414
|
+
action: from that directory run `grok --trust` (or `/hooks-trust` in a grok session), then retry",
|
|
415
|
+
cwd.display(),
|
|
416
|
+
cwd.display()
|
|
417
|
+
)));
|
|
418
|
+
}
|
|
408
419
|
Ok(())
|
|
409
420
|
}
|
|
410
421
|
|
|
@@ -419,6 +430,44 @@ fn grok_auth_present(grok_home: &Path) -> bool {
|
|
|
419
430
|
value.as_object().is_some_and(|map| !map.is_empty())
|
|
420
431
|
}
|
|
421
432
|
|
|
433
|
+
fn folder_trust_required() -> bool {
|
|
434
|
+
!matches!(
|
|
435
|
+
std::env::var("GROK_FOLDER_TRUST").ok().as_deref(),
|
|
436
|
+
Some("0") | Some("false") | Some("off")
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
fn grok_folder_is_trusted(grok_home: &Path, cwd: &Path) -> bool {
|
|
441
|
+
let text = std::fs::read_to_string(grok_home.join("trusted_folders.toml")).unwrap_or_default();
|
|
442
|
+
let cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
|
|
443
|
+
grok_trusted_folders(&text).into_iter().any(|trusted| {
|
|
444
|
+
let trusted = std::fs::canonicalize(&trusted).unwrap_or(trusted);
|
|
445
|
+
cwd == trusted || cwd.starts_with(&trusted)
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
fn grok_trusted_folders(text: &str) -> Vec<PathBuf> {
|
|
450
|
+
let mut current = None;
|
|
451
|
+
let mut out = Vec::new();
|
|
452
|
+
for line in text.lines() {
|
|
453
|
+
let trimmed = line.trim();
|
|
454
|
+
if let Some(rest) = trimmed.strip_prefix("[folders.\"") {
|
|
455
|
+
current = rest.strip_suffix("\"]").map(str::to_string);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if trimmed.starts_with('[') {
|
|
459
|
+
current = None;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if trimmed == "trusted = true" {
|
|
463
|
+
if let Some(path) = current.take() {
|
|
464
|
+
out.push(PathBuf::from(path));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
out
|
|
469
|
+
}
|
|
470
|
+
|
|
422
471
|
/// ---
|
|
423
472
|
/// purpose: 写 workspace 下 grok 的项目作用域 MCP 配置,服务名固定为 team_orchestrator
|
|
424
473
|
/// params:
|
|
@@ -151,14 +151,30 @@ pub(crate) fn parse_pi_list_models_table(bytes: &[u8]) -> Result<Vec<String>, Pr
|
|
|
151
151
|
if line.trim().is_empty() {
|
|
152
152
|
continue;
|
|
153
153
|
}
|
|
154
|
-
let
|
|
155
|
-
|
|
156
|
-
return Err(ProviderError::Command(format!(
|
|
154
|
+
let malformed = || {
|
|
155
|
+
ProviderError::Command(format!(
|
|
157
156
|
"Pi model catalog row {} is malformed",
|
|
158
157
|
index + 2
|
|
159
|
-
))
|
|
158
|
+
))
|
|
159
|
+
};
|
|
160
|
+
let (provider, model) = line
|
|
161
|
+
.trim()
|
|
162
|
+
.split_once(char::is_whitespace)
|
|
163
|
+
.ok_or_else(malformed)?;
|
|
164
|
+
let mut model = model.trim();
|
|
165
|
+
// Pi prints the raw model id, which may contain spaces. Only the
|
|
166
|
+
// capability columns following it are single whitespace-free values.
|
|
167
|
+
for _ in 2..header_columns.len() {
|
|
168
|
+
model = model
|
|
169
|
+
.rsplit_once(char::is_whitespace)
|
|
170
|
+
.ok_or_else(malformed)?
|
|
171
|
+
.0
|
|
172
|
+
.trim_end();
|
|
173
|
+
}
|
|
174
|
+
if model.is_empty() {
|
|
175
|
+
return Err(malformed());
|
|
160
176
|
}
|
|
161
|
-
let exact = format!("{}/{}"
|
|
177
|
+
let exact = format!("{provider}/{model}");
|
|
162
178
|
if !seen.insert(exact.clone()) {
|
|
163
179
|
return Err(ProviderError::Command(format!(
|
|
164
180
|
"Pi model catalog contains duplicate exact id {exact:?}"
|
|
@@ -642,6 +658,64 @@ fn observe_pi_catalog_test(f: impl FnOnce(&mut PiCatalogTestContext)) {
|
|
|
642
658
|
});
|
|
643
659
|
}
|
|
644
660
|
|
|
661
|
+
fn spawn_catalog_reader(
|
|
662
|
+
stdout: std::process::ChildStdout,
|
|
663
|
+
max_bytes: u64,
|
|
664
|
+
) -> mpsc::Receiver<(std::io::Result<usize>, Vec<u8>)> {
|
|
665
|
+
let (sender, receiver) = mpsc::channel();
|
|
666
|
+
std::thread::spawn(move || {
|
|
667
|
+
let mut bytes = Vec::new();
|
|
668
|
+
let result = stdout.take(max_bytes + 1).read_to_end(&mut bytes);
|
|
669
|
+
let _ = sender.send((result, bytes));
|
|
670
|
+
});
|
|
671
|
+
receiver
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
fn finish_catalog_reader(
|
|
675
|
+
receiver: mpsc::Receiver<(std::io::Result<usize>, Vec<u8>)>,
|
|
676
|
+
status: std::process::ExitStatus,
|
|
677
|
+
timeout: Duration,
|
|
678
|
+
max_bytes: u64,
|
|
679
|
+
) -> Result<Vec<u8>, String> {
|
|
680
|
+
let (result, bytes) = match receiver.recv_timeout(timeout) {
|
|
681
|
+
Ok(value) => value,
|
|
682
|
+
Err(_) => {
|
|
683
|
+
#[cfg(test)]
|
|
684
|
+
observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
|
|
685
|
+
return Err("Pi model catalog command timed out".to_string());
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
if !status.success() {
|
|
689
|
+
return Err("Pi model catalog command failed".into());
|
|
690
|
+
}
|
|
691
|
+
result.map_err(|_| "Pi model catalog could not be read".to_string())?;
|
|
692
|
+
if bytes.len() as u64 > max_bytes {
|
|
693
|
+
return Err("Pi model catalog exceeds the bounded output limit".into());
|
|
694
|
+
}
|
|
695
|
+
Ok(bytes)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
#[cfg(test)]
|
|
699
|
+
pub(crate) fn run_pi_catalog_after_parent_exit_for_test(
|
|
700
|
+
stdout: std::process::ChildStdout,
|
|
701
|
+
parent_pid: u32,
|
|
702
|
+
status: std::process::ExitStatus,
|
|
703
|
+
timeout: Duration,
|
|
704
|
+
max_bytes: u64,
|
|
705
|
+
) -> Result<Vec<u8>, String> {
|
|
706
|
+
observe_pi_catalog_test(|context| {
|
|
707
|
+
context.observation.parent_pid = Some(parent_pid);
|
|
708
|
+
context.observation.parent_exit_success = Some(status.success());
|
|
709
|
+
context.observation.parent_exit_code = status.code();
|
|
710
|
+
});
|
|
711
|
+
finish_catalog_reader(
|
|
712
|
+
spawn_catalog_reader(stdout, max_bytes),
|
|
713
|
+
status,
|
|
714
|
+
timeout,
|
|
715
|
+
max_bytes,
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
645
719
|
/// Discover candidates with one PATH-first catalog observation. This deliberately
|
|
646
720
|
/// does not resolve adapters or scan wrapper chains; callers use it only for
|
|
647
721
|
/// rejecting an unqualified role model before lifecycle mutation.
|
|
@@ -685,12 +759,7 @@ pub(crate) fn run_pi_catalog(
|
|
|
685
759
|
let _ = child.wait();
|
|
686
760
|
"Pi model catalog output unavailable".to_string()
|
|
687
761
|
})?;
|
|
688
|
-
let
|
|
689
|
-
std::thread::spawn(move || {
|
|
690
|
-
let mut bytes = Vec::new();
|
|
691
|
-
let result = stdout.take(max_bytes + 1).read_to_end(&mut bytes);
|
|
692
|
-
let _ = sender.send((result, bytes));
|
|
693
|
-
});
|
|
762
|
+
let receiver = spawn_catalog_reader(stdout, max_bytes);
|
|
694
763
|
let start = Instant::now();
|
|
695
764
|
loop {
|
|
696
765
|
match child.try_wait() {
|
|
@@ -700,18 +769,12 @@ pub(crate) fn run_pi_catalog(
|
|
|
700
769
|
context.observation.parent_exit_success = Some(status.success());
|
|
701
770
|
context.observation.parent_exit_code = status.code();
|
|
702
771
|
});
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
};
|
|
711
|
-
if !status.success() { return Err("Pi model catalog command failed".into()); }
|
|
712
|
-
result.map_err(|_| "Pi model catalog could not be read".to_string())?;
|
|
713
|
-
if bytes.len() as u64 > max_bytes { return Err("Pi model catalog exceeds the bounded output limit".into()); }
|
|
714
|
-
return Ok(bytes);
|
|
772
|
+
return finish_catalog_reader(
|
|
773
|
+
receiver,
|
|
774
|
+
status,
|
|
775
|
+
timeout.saturating_sub(start.elapsed()),
|
|
776
|
+
max_bytes,
|
|
777
|
+
);
|
|
715
778
|
}
|
|
716
779
|
Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(20)),
|
|
717
780
|
Ok(None) => {
|