@team-agent/installer 0.5.50 → 0.5.52

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.
Files changed (93) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +46 -115
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  25. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  26. package/crates/team-agent/src/cli/types.rs +1 -0
  27. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  29. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  30. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  31. package/crates/team-agent/src/db/message_store.rs +154 -44
  32. package/crates/team-agent/src/event_log.rs +73 -0
  33. package/crates/team-agent/src/leader/lease.rs +202 -16
  34. package/crates/team-agent/src/leader/start.rs +28 -4
  35. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  36. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
  37. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  38. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  39. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  41. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  42. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  43. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  44. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  45. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  46. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  48. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  49. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  50. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  52. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  53. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  54. package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
  55. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  56. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
  57. package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
  58. package/crates/team-agent/src/lifecycle/restart.rs +26 -2
  59. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
  60. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
  61. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  62. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  63. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  64. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  65. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  66. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  67. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  68. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  69. package/crates/team-agent/src/messaging/activity.rs +4 -2
  70. package/crates/team-agent/src/messaging/address.rs +86 -0
  71. package/crates/team-agent/src/messaging/delivery.rs +288 -183
  72. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  73. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  74. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  75. package/crates/team-agent/src/messaging/mod.rs +15 -2
  76. package/crates/team-agent/src/messaging/persist.rs +309 -0
  77. package/crates/team-agent/src/messaging/results.rs +16 -24
  78. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  79. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  80. package/crates/team-agent/src/messaging/send.rs +101 -49
  81. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +284 -21
  88. package/crates/team-agent/src/state/projection.rs +245 -30
  89. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  90. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  91. package/crates/team-agent/src/state/repository.rs +94 -27
  92. package/crates/team-agent/src/state/selector.rs +9 -18
  93. package/package.json +4 -4
@@ -0,0 +1,478 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+ use std::path::{Path, PathBuf};
3
+ use std::process::Command;
4
+
5
+ use crate::lifecycle::profile_launch::parse_provider;
6
+ use crate::lifecycle::*;
7
+ use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
8
+ use crate::model::ids::AgentId;
9
+ use crate::model::permissions::{self, AgentPermissionInput};
10
+ use crate::model::yaml::{self, Value};
11
+ use crate::state::persist::load_runtime_state;
12
+ use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
13
+
14
+ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
15
+
16
+ use super::*;
17
+
18
+ /// B-7 / 036b — TEAM_AGENT_LEADER_PANE_ID 主动路径 fail-fast helper。
19
+ /// 入口形态(N38 三行式):
20
+ /// error : `TEAM_AGENT_LEADER_PANE_ID points at a dead/absent pane: %<id>`
21
+ /// action : `unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id`
22
+ /// log : `TEAM_AGENT_LEADER_PANE_ID=%<id>`
23
+ /// env 未设(或空)→ Ok(())。
24
+ /// env 设了但 pane 可判定为 Dead/Absent → Err(RequirementUnmet)。
25
+ /// 真实 tmux 后端跨所有现存 tmux socket server 探测:TEAM_AGENT_LEADER_PANE_ID 是用户
26
+ /// override 指针,不归属当前 team socket。
27
+ /// probe 返 Unknown 不挡(被动路径降级):本主动路径只对【显式 Dead/Absent】fail-fast,
28
+ /// MUST-17 不过度设计 / unset 走 pass-through(b7_unset_leader_pane_env_passes_through 守)。
29
+ pub(crate) fn validate_active_leader_pane_env(
30
+ transport: &dyn Transport,
31
+ ) -> Result<(), LifecycleError> {
32
+ validate_active_leader_pane_env_with_workspaces(transport, &[])
33
+ }
34
+
35
+ pub(crate) fn validate_active_leader_pane_env_with_workspace(
36
+ transport: &dyn Transport,
37
+ workspace: Option<&Path>,
38
+ ) -> Result<(), LifecycleError> {
39
+ let workspaces = workspace.into_iter().collect::<Vec<_>>();
40
+ validate_active_leader_pane_env_with_workspaces(transport, &workspaces)
41
+ }
42
+
43
+ pub(crate) fn validate_active_leader_pane_env_with_workspaces(
44
+ transport: &dyn Transport,
45
+ workspaces: &[&Path],
46
+ ) -> Result<(), LifecycleError> {
47
+ let pane_id_raw = match std::env::var("TEAM_AGENT_LEADER_PANE_ID") {
48
+ Ok(v) if !v.is_empty() => v,
49
+ _ => return Ok(()),
50
+ };
51
+ let pane = crate::transport::PaneId::new(&pane_id_raw);
52
+ if !is_tmux_pane_id_format(&pane) {
53
+ write_invalid_leader_pane_env_warning(workspaces, &pane_id_raw);
54
+ return Ok(());
55
+ }
56
+ let failure = match leader_pane_env_state_for_validation(transport, &pane) {
57
+ LeaderPaneEnvState::Dead => Some("dead"),
58
+ LeaderPaneEnvState::Absent => Some("absent"),
59
+ LeaderPaneEnvState::Live | LeaderPaneEnvState::Unknown => None,
60
+ };
61
+ let Some(reason) = failure else {
62
+ return Ok(());
63
+ };
64
+ Err(LifecycleError::RequirementUnmet(format!(
65
+ "TEAM_AGENT_LEADER_PANE_ID points at a {reason} pane: {pane_id_raw}\n\
66
+ action: unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id\n\
67
+ log: TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}"
68
+ )))
69
+ }
70
+
71
+ pub(super) fn write_invalid_leader_pane_env_warning(workspaces: &[&Path], pane_id_raw: &str) {
72
+ let message = "invalid pane id format, skipping validation";
73
+ let mut wrote = false;
74
+ let mut errors = Vec::new();
75
+ let mut seen = BTreeSet::new();
76
+ for workspace in workspaces {
77
+ let key = workspace.to_string_lossy().to_string();
78
+ if !seen.insert(key.clone()) {
79
+ continue;
80
+ }
81
+ match crate::event_log::EventLog::new(workspace).write(
82
+ "leader_pane_env.validation_warning",
83
+ serde_json::json!({
84
+ "env": "TEAM_AGENT_LEADER_PANE_ID",
85
+ "value": pane_id_raw,
86
+ "warning": message,
87
+ }),
88
+ ) {
89
+ Ok(_) => wrote = true,
90
+ Err(err) => errors.push(format!("{key}: {err}")),
91
+ }
92
+ }
93
+ if !wrote {
94
+ eprintln!("TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}: {message}");
95
+ if !errors.is_empty() {
96
+ eprintln!(
97
+ "TEAM_AGENT_LEADER_PANE_ID warning event write failed: {}",
98
+ errors.join("; ")
99
+ );
100
+ }
101
+ }
102
+ }
103
+
104
+ pub(super) fn warn_ignored_owner_team_id(
105
+ workspace: &Path,
106
+ team_dir: &Path,
107
+ runtime_team_key: &str,
108
+ ) {
109
+ let Ok(Some(ignored)) = crate::compiler::ignored_owner_team_id_from_team_md(team_dir) else {
110
+ return;
111
+ };
112
+ eprintln!(
113
+ "Warning: ignored TEAM.md {}={}",
114
+ ignored.field, ignored.value
115
+ );
116
+ eprintln!("Reason: owner identity is the canonical runtime team key ({runtime_team_key}), not TEAM.md front matter");
117
+ eprintln!("Action: remove {} from TEAM.md", ignored.field);
118
+ if let Err(err) = crate::event_log::EventLog::new(workspace).write(
119
+ "spec.field_ignored",
120
+ serde_json::json!({
121
+ "field": ignored.field,
122
+ "source": team_dir.join("TEAM.md").to_string_lossy().to_string(),
123
+ "value": ignored.value,
124
+ "warning": "ignored user-set owner_team_id",
125
+ "reason": "owner identity is derived from the canonical runtime team key",
126
+ "action": "remove owner_team_id from TEAM.md",
127
+ "runtime_team_key": runtime_team_key,
128
+ }),
129
+ ) {
130
+ eprintln!("Warning: spec.field_ignored event write failed: {err}");
131
+ }
132
+ }
133
+
134
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
135
+ pub(crate) enum LeaderPaneEnvState {
136
+ Live,
137
+ Dead,
138
+ Absent,
139
+ Unknown,
140
+ }
141
+
142
+ pub(super) fn leader_pane_env_state_for_validation(
143
+ transport: &dyn Transport,
144
+ pane: &crate::transport::PaneId,
145
+ ) -> LeaderPaneEnvState {
146
+ if !is_tmux_pane_id_format(pane) {
147
+ return LeaderPaneEnvState::Unknown;
148
+ }
149
+ if transport.probes_real_tmux_socket_roots() {
150
+ return active_leader_pane_state_across_tmux_sockets(pane);
151
+ }
152
+ active_leader_pane_state(transport, pane)
153
+ }
154
+
155
+ pub(super) fn is_tmux_pane_id_format(pane: &crate::transport::PaneId) -> bool {
156
+ let pane = pane.as_str();
157
+ pane.len() > 1 && pane.starts_with('%') && pane[1..].chars().all(|ch| ch.is_ascii_digit())
158
+ }
159
+
160
+ pub(super) fn active_leader_pane_state_across_tmux_sockets(
161
+ pane: &crate::transport::PaneId,
162
+ ) -> LeaderPaneEnvState {
163
+ let endpoints = crate::tmux_backend::tmux_socket_endpoints();
164
+ let transports = endpoints
165
+ .iter()
166
+ .map(|endpoint| crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint))
167
+ .collect::<Vec<_>>();
168
+ active_leader_pane_state_across_transports(
169
+ transports
170
+ .iter()
171
+ .map(|transport| transport as &dyn Transport),
172
+ pane,
173
+ )
174
+ }
175
+
176
+ pub(crate) fn active_leader_pane_state_across_transports<'a>(
177
+ transports: impl IntoIterator<Item = &'a dyn Transport>,
178
+ pane: &crate::transport::PaneId,
179
+ ) -> LeaderPaneEnvState {
180
+ let mut found_absent = false;
181
+ let mut found_dead = false;
182
+ for transport in transports {
183
+ match active_leader_pane_state(transport, pane) {
184
+ LeaderPaneEnvState::Live => return LeaderPaneEnvState::Live,
185
+ LeaderPaneEnvState::Dead => found_dead = true,
186
+ LeaderPaneEnvState::Absent => found_absent = true,
187
+ LeaderPaneEnvState::Unknown => {}
188
+ }
189
+ }
190
+ if found_dead {
191
+ LeaderPaneEnvState::Dead
192
+ } else if found_absent {
193
+ LeaderPaneEnvState::Absent
194
+ } else {
195
+ LeaderPaneEnvState::Unknown
196
+ }
197
+ }
198
+
199
+ pub(super) fn active_leader_pane_state(
200
+ transport: &dyn Transport,
201
+ pane: &crate::transport::PaneId,
202
+ ) -> LeaderPaneEnvState {
203
+ match transport.has_pane(pane) {
204
+ Ok(Some(true)) => return LeaderPaneEnvState::Live,
205
+ Ok(Some(false)) => return LeaderPaneEnvState::Absent,
206
+ Ok(None) | Err(_) => {}
207
+ }
208
+ match transport.liveness(pane) {
209
+ Ok(crate::transport::PaneLiveness::Live) => LeaderPaneEnvState::Live,
210
+ Ok(crate::transport::PaneLiveness::Dead) => LeaderPaneEnvState::Dead,
211
+ Ok(crate::transport::PaneLiveness::Unknown) | Err(_) => LeaderPaneEnvState::Unknown,
212
+ }
213
+ }
214
+
215
+ pub(super) fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
216
+ let Some(owner) = unbound_launched_owner(launched, launched_key) else {
217
+ return;
218
+ };
219
+ let Some(provider) = owner
220
+ .get("provider")
221
+ .and_then(serde_json::Value::as_str)
222
+ .filter(|provider| !provider.is_empty())
223
+ else {
224
+ return;
225
+ };
226
+ let owner_epoch = 1u64;
227
+ let receiver = serde_json::json!({
228
+ "mode": "direct_tmux",
229
+ "status": "unbound",
230
+ "provider": provider,
231
+ "leader_session_uuid": owner.get("leader_session_uuid").cloned().unwrap_or(serde_json::Value::Null),
232
+ "owner_epoch": owner_epoch,
233
+ "discovery": "quick_start",
234
+ });
235
+ // Stage 3a (identity-boundary unified plan, architect direction 2026-06-23):
236
+ // route quick-start unbound seed through ownership repository.
237
+ let record = crate::state::ownership::OwnershipWrite::new()
238
+ .with_leader_receiver(receiver)
239
+ .with_team_owner(owner)
240
+ .with_owner_epoch(owner_epoch);
241
+ crate::state::ownership::write_owner(launched, launched_key, record);
242
+ }
243
+
244
+ pub(super) fn unbound_launched_owner(
245
+ launched: &serde_json::Value,
246
+ launched_key: &str,
247
+ ) -> Option<serde_json::Value> {
248
+ let provider = unbound_launched_provider(launched)?;
249
+ let machine_fingerprint = launched
250
+ .get("team_owner")
251
+ .and_then(|owner| owner.get("machine_fingerprint"))
252
+ .and_then(serde_json::Value::as_str)
253
+ .unwrap_or("");
254
+ let workspace = launched
255
+ .get("workspace")
256
+ .and_then(serde_json::Value::as_str)
257
+ .unwrap_or("");
258
+ let os_user = std::env::var("USER")
259
+ .or_else(|_| std::env::var("USERNAME"))
260
+ .unwrap_or_default();
261
+ let uuid = crate::model::ids::LeaderSessionUuid::derive(
262
+ machine_fingerprint,
263
+ workspace,
264
+ &os_user,
265
+ launched_key,
266
+ )
267
+ .ok()?;
268
+ Some(serde_json::json!({
269
+ "provider": provider,
270
+ "machine_fingerprint": machine_fingerprint,
271
+ "leader_session_uuid": uuid.as_str(),
272
+ "owner_epoch": 1u64,
273
+ "claimed_at": spawn_timestamp(),
274
+ "claimed_via": "quick-start",
275
+ "os_user": os_user,
276
+ }))
277
+ }
278
+
279
+ pub(super) fn unbound_launched_provider(launched: &serde_json::Value) -> Option<String> {
280
+ if let Some(provider) = launched
281
+ .get("team_owner")
282
+ .and_then(|owner| owner.get("provider"))
283
+ .and_then(serde_json::Value::as_str)
284
+ .filter(|provider| !provider.is_empty())
285
+ .and_then(parse_provider)
286
+ .and_then(provider_wire_string)
287
+ {
288
+ return Some(provider);
289
+ }
290
+ let pane = launched
291
+ .get("team_owner")
292
+ .and_then(|owner| owner.get("pane_id"))
293
+ .and_then(serde_json::Value::as_str)
294
+ .filter(|pane| !pane.is_empty())?;
295
+ let target = PaneId::new(pane);
296
+ attributed_provider_for_pane_across_tmux_sockets(&target).and_then(provider_wire_string)
297
+ }
298
+
299
+ pub(super) fn provider_wire_string(provider: Provider) -> Option<String> {
300
+ serde_json::to_value(provider)
301
+ .ok()
302
+ .and_then(|value| value.as_str().map(str::to_string))
303
+ }
304
+
305
+ pub(super) fn attributed_provider_for_pane_across_tmux_sockets(pane: &PaneId) -> Option<Provider> {
306
+ crate::tmux_backend::tmux_socket_endpoints()
307
+ .into_iter()
308
+ .filter_map(|endpoint| {
309
+ crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&endpoint)
310
+ .list_targets()
311
+ .ok()
312
+ })
313
+ .flatten()
314
+ .find(|info| info.pane_id == *pane)
315
+ .and_then(|info| crate::leader::attribute_pane_provider(&info))
316
+ }
317
+
318
+ pub(super) fn caller_provider_for_seed_with_lookup(
319
+ caller: &crate::state::owner_gate::CallerIdentity,
320
+ lookup_pane_provider: impl Fn(&PaneId) -> Option<Provider>,
321
+ ) -> Option<String> {
322
+ if !caller.provider.is_empty() {
323
+ if let Some(provider) = parse_provider(&caller.provider).and_then(provider_wire_string) {
324
+ return Some(provider);
325
+ }
326
+ }
327
+ (!caller.pane_id.is_empty())
328
+ .then(|| PaneId::new(&caller.pane_id))
329
+ .and_then(|pane| lookup_pane_provider(&pane))
330
+ .and_then(provider_wire_string)
331
+ }
332
+
333
+ #[cfg(test)]
334
+ mod e22_unbound_owner_provider_tests {
335
+ use super::*;
336
+ use crate::state::owner_gate::CallerIdentity;
337
+
338
+ #[test]
339
+ fn unbound_owner_preserves_explicit_copilot_provider() {
340
+ let mut launched = serde_json::json!({
341
+ "workspace": "/tmp/team-agent-e22",
342
+ "team_owner": {
343
+ "provider": "copilot",
344
+ "machine_fingerprint": "machine"
345
+ }
346
+ });
347
+
348
+ seed_unbound_launched_owner(&mut launched, "team-e22");
349
+
350
+ // Stage 3d: canonical owner/receiver at teams.team-e22.
351
+ assert_eq!(
352
+ launched
353
+ .pointer("/teams/team-e22/team_owner/provider")
354
+ .and_then(serde_json::Value::as_str),
355
+ Some("copilot")
356
+ );
357
+ assert_eq!(
358
+ launched
359
+ .pointer("/teams/team-e22/leader_receiver/provider")
360
+ .and_then(serde_json::Value::as_str),
361
+ Some("copilot")
362
+ );
363
+ }
364
+
365
+ #[test]
366
+ fn unbound_owner_without_attributed_provider_does_not_default_codex() {
367
+ let mut launched = serde_json::json!({
368
+ "workspace": "/tmp/team-agent-e22",
369
+ "team_owner": {
370
+ "machine_fingerprint": "machine"
371
+ }
372
+ });
373
+
374
+ seed_unbound_launched_owner(&mut launched, "team-e22");
375
+
376
+ // Stage 3d: no receiver should be seeded at canonical location.
377
+ assert!(
378
+ launched
379
+ .pointer("/teams/team-e22/leader_receiver")
380
+ .is_none(),
381
+ "unattributed unbound owner must not seed a codex receiver: {launched}"
382
+ );
383
+ assert!(
384
+ launched
385
+ .pointer("/teams/team-e22/team_owner/provider")
386
+ .and_then(serde_json::Value::as_str)
387
+ != Some("codex"),
388
+ "unattributed unbound owner must not silently become codex: {launched}"
389
+ );
390
+ }
391
+
392
+ fn caller(provider: &str, pane_id: &str) -> CallerIdentity {
393
+ CallerIdentity {
394
+ pane_id: pane_id.to_string(),
395
+ provider: provider.to_string(),
396
+ machine_fingerprint: "machine".to_string(),
397
+ leader_session_uuid: "leader-uuid".to_string(),
398
+ leader_session_uuid_source: "derived".to_string(),
399
+ }
400
+ }
401
+
402
+ #[test]
403
+ fn env_seed_attributes_in_tmux_node_form_copilot_from_caller_pane() {
404
+ let mut state = serde_json::json!({
405
+ "workspace": "/tmp/team-agent-e22",
406
+ "leader": {"provider": "copilot"},
407
+ });
408
+
409
+ assert!(seed_launched_owner_from_caller_with_provider_lookup(
410
+ &mut state,
411
+ caller("", "%0"),
412
+ |pane| (pane.as_str() == "%0").then_some(Provider::Copilot),
413
+ ));
414
+
415
+ // Stage 3d: canonical owner/receiver at teams.<team_state_key>.
416
+ // State has no team_key/team_dir/spec_path → fallback "current".
417
+ let team_key = crate::state::projection::team_state_key(&state);
418
+ assert_eq!(
419
+ state["teams"][&team_key]["team_owner"]["provider"].as_str(),
420
+ Some("copilot")
421
+ );
422
+ assert_eq!(
423
+ state["teams"][&team_key]["leader_receiver"]["provider"].as_str(),
424
+ Some("copilot")
425
+ );
426
+ }
427
+
428
+ #[test]
429
+ fn env_seed_unknown_caller_pane_does_not_default_codex() {
430
+ let mut state = serde_json::json!({
431
+ "workspace": "/tmp/team-agent-e22",
432
+ "leader": {"provider": "copilot"},
433
+ });
434
+
435
+ assert!(seed_launched_owner_from_caller_with_provider_lookup(
436
+ &mut state,
437
+ caller("", "%0"),
438
+ |_| None,
439
+ ));
440
+ let team_key = crate::state::projection::team_state_key(&state);
441
+ assert_eq!(
442
+ state["teams"][&team_key]["team_owner"]["pane_id"].as_str(),
443
+ Some("%0")
444
+ );
445
+ assert_eq!(
446
+ state["teams"][&team_key]["leader_receiver"]["pane_id"].as_str(),
447
+ Some("%0")
448
+ );
449
+ assert!(
450
+ state["teams"][&team_key]["leader_receiver"]["provider"].as_str() != Some("codex"),
451
+ "unknown caller pane must not silently seed a codex receiver: {state}"
452
+ );
453
+ assert!(
454
+ state["teams"][&team_key]["team_owner"]["provider"].as_str() != Some("codex"),
455
+ "unknown caller pane must not silently become codex: {state}"
456
+ );
457
+ }
458
+ }
459
+
460
+ pub(super) fn owner_pane_belongs_to_other_team(
461
+ existing: &serde_json::Value,
462
+ launched_key: &str,
463
+ pane: &str,
464
+ ) -> bool {
465
+ existing
466
+ .get("teams")
467
+ .and_then(serde_json::Value::as_object)
468
+ .is_some_and(|teams| {
469
+ teams.iter().any(|(key, team)| {
470
+ key != launched_key
471
+ && team
472
+ .get("team_owner")
473
+ .and_then(|owner| owner.get("pane_id"))
474
+ .and_then(serde_json::Value::as_str)
475
+ == Some(pane)
476
+ })
477
+ })
478
+ }
@@ -0,0 +1,201 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+ use std::path::{Path, PathBuf};
3
+ use std::process::Command;
4
+
5
+ use crate::lifecycle::*;
6
+ use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
7
+ use crate::model::ids::AgentId;
8
+ use crate::model::permissions::{self, AgentPermissionInput};
9
+ use crate::model::yaml::{self, Value};
10
+ use crate::state::persist::load_runtime_state;
11
+ use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
12
+
13
+ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
14
+
15
+ use super::*;
16
+
17
+ pub(crate) fn resolve_mcp_config(
18
+ config: crate::provider::McpConfig,
19
+ workspace: &Path,
20
+ agent_id: &str,
21
+ team_id: &str,
22
+ ) -> crate::provider::McpConfig {
23
+ crate::provider::McpConfig {
24
+ raw: resolve_mcp_placeholders(config.raw, workspace, agent_id, team_id),
25
+ }
26
+ }
27
+
28
+ pub(super) fn resolve_mcp_placeholders(
29
+ value: serde_json::Value,
30
+ workspace: &Path,
31
+ agent_id: &str,
32
+ team_id: &str,
33
+ ) -> serde_json::Value {
34
+ match value {
35
+ serde_json::Value::String(s) => serde_json::Value::String(
36
+ s.replace("{workspace}", &workspace.to_string_lossy())
37
+ .replace("{agent_id}", agent_id)
38
+ .replace("{team_id}", team_id),
39
+ ),
40
+ serde_json::Value::Array(items) => serde_json::Value::Array(
41
+ items
42
+ .into_iter()
43
+ .map(|item| resolve_mcp_placeholders(item, workspace, agent_id, team_id))
44
+ .collect(),
45
+ ),
46
+ serde_json::Value::Object(map) => serde_json::Value::Object(
47
+ map.into_iter()
48
+ .map(|(key, value)| {
49
+ (
50
+ key,
51
+ resolve_mcp_placeholders(value, workspace, agent_id, team_id),
52
+ )
53
+ })
54
+ .collect(),
55
+ ),
56
+ other => other,
57
+ }
58
+ }
59
+
60
+ pub(crate) fn write_worker_mcp_config(
61
+ workspace: &Path,
62
+ agent_id: &str,
63
+ config: &crate::provider::McpConfig,
64
+ ) -> Result<PathBuf, LifecycleError> {
65
+ write_worker_mcp_config_for_provider(workspace, agent_id, config, None)
66
+ }
67
+
68
+ /// C-3-4 cr verdict v2 — Copilot 的 mcp config schema 字段名是 `transport`
69
+ /// (实测 cmd-mcp-add 原文取值 stdio|http|sse),不是 canonical 的 `type`。当
70
+ /// provider==Copilot 时写出文件前先做 type→transport 翻译;其它 provider 不动。
71
+ /// 文件路径同 canonical `<ws>/.team/runtime/mcp/<agent_id>.json`,因为 launch
72
+ /// 路径会用 `--additional-mcp-config @<file>` 直指它。
73
+ pub(crate) fn write_worker_mcp_config_for_provider(
74
+ workspace: &Path,
75
+ agent_id: &str,
76
+ config: &crate::provider::McpConfig,
77
+ provider: Option<Provider>,
78
+ ) -> Result<PathBuf, LifecycleError> {
79
+ let path = workspace
80
+ .join(".team/runtime/mcp")
81
+ .join(format!("{agent_id}.json"));
82
+ if let Some(parent) = path.parent() {
83
+ std::fs::create_dir_all(parent)
84
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
85
+ }
86
+ let raw = if matches!(provider, Some(Provider::Copilot)) {
87
+ copilot_translate_mcp_servers(&config.raw)
88
+ } else {
89
+ config.raw.clone()
90
+ };
91
+ let body = serde_json::to_string_pretty(&serde_json::json!({"mcpServers": raw}))
92
+ .map_err(|e| LifecycleError::StatePersist(format!("serialize mcp config: {e}")))?;
93
+ std::fs::write(&path, body)
94
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
95
+ Ok(path)
96
+ }
97
+
98
+ /// C-3-4 cr verdict v2 — McpConfig.raw 是 `{name: {type, command, args, env}}` 形;
99
+ /// copilot mcp add schema 取 `transport` 替 `type`(stdio|http|sse 同值)。仅
100
+ /// 字段名变换,其余字段全保留。
101
+ pub(super) fn copilot_translate_mcp_servers(raw: &serde_json::Value) -> serde_json::Value {
102
+ let Some(servers) = raw.as_object() else {
103
+ return raw.clone();
104
+ };
105
+ let mut translated = serde_json::Map::new();
106
+ for (name, server) in servers {
107
+ let Some(obj) = server.as_object() else {
108
+ translated.insert(name.clone(), server.clone());
109
+ continue;
110
+ };
111
+ let mut out = serde_json::Map::new();
112
+ for (key, value) in obj {
113
+ if key == "type" {
114
+ out.insert("transport".to_string(), value.clone());
115
+ } else {
116
+ out.insert(key.clone(), value.clone());
117
+ }
118
+ }
119
+ translated.insert(name.clone(), serde_json::Value::Object(out));
120
+ }
121
+ serde_json::Value::Object(translated)
122
+ }
123
+
124
+ pub(crate) fn point_native_mcp_config_at_file(
125
+ argv: &mut [String],
126
+ provider: Provider,
127
+ path: &Path,
128
+ ) {
129
+ match provider {
130
+ Provider::Claude | Provider::ClaudeCode => {
131
+ let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
132
+ return;
133
+ };
134
+ if let Some(value) = argv.get_mut(index.saturating_add(1)) {
135
+ *value = path.to_string_lossy().to_string();
136
+ }
137
+ }
138
+ // §C1 note: copilot `--additional-mcp-config` 接受 `@file`,直接指向既有
139
+ // `.team/runtime/mcp/<agent>.json`(launch 路径 write_worker_mcp_config 已写)。
140
+ // 既避免 inline JSON 包 mcpServers wrapper 的语义错位,也更利于 ps 验法。
141
+ Provider::Copilot => {
142
+ let Some(index) = argv.iter().position(|arg| arg == "--additional-mcp-config") else {
143
+ return;
144
+ };
145
+ if let Some(value) = argv.get_mut(index.saturating_add(1)) {
146
+ *value = format!("@{}", path.to_string_lossy());
147
+ }
148
+ }
149
+ _ => {}
150
+ }
151
+ }
152
+
153
+ pub(super) fn permissions_json(
154
+ agent: &Value,
155
+ id: &str,
156
+ provider: Provider,
157
+ ) -> Result<serde_json::Value, crate::model::ModelError> {
158
+ let tools = agent.get("tools").and_then(Value::as_list).map(|items| {
159
+ items
160
+ .iter()
161
+ .filter_map(Value::as_str)
162
+ .map(str::to_string)
163
+ .collect::<Vec<_>>()
164
+ });
165
+ let resolved = permissions::resolve_permissions(&AgentPermissionInput {
166
+ id: Some(AgentId::new(id)),
167
+ provider,
168
+ role: agent
169
+ .get("role")
170
+ .and_then(Value::as_str)
171
+ .map(str::to_string),
172
+ tools,
173
+ })?;
174
+ let mut out = serde_json::Map::new();
175
+ out.insert("agent_id".to_string(), serde_json::json!(id));
176
+ out.insert("provider".to_string(), serde_json::json!(provider));
177
+ out.insert(
178
+ "tools".to_string(),
179
+ serde_json::json!(resolved.sorted_tool_strings()),
180
+ );
181
+ out.insert(
182
+ "resolved_tools".to_string(),
183
+ serde_json::Value::Array(
184
+ resolved
185
+ .resolved_tools
186
+ .iter()
187
+ .map(|tool| {
188
+ serde_json::json!({
189
+ "tool": tool.tool,
190
+ "enforcement": tool.enforcement,
191
+ })
192
+ })
193
+ .collect(),
194
+ ),
195
+ );
196
+ out.insert(
197
+ "has_prompt_only".to_string(),
198
+ serde_json::json!(resolved.has_prompt_only),
199
+ );
200
+ Ok(serde_json::Value::Object(out))
201
+ }