@team-agent/installer 0.3.27 → 0.3.28

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.
@@ -0,0 +1,277 @@
1
+ //! 0.3.28 layout step 1 — session name constructors + topology invariant guard.
2
+ //!
3
+ //! Python 0.2.11 truth source (recap):
4
+ //! * Leader runs in a SEPARATE tmux session named
5
+ //! `team-agent-leader-<provider>-<folder>-<sha1[:8]>` (the existing
6
+ //! `crate::leader::start::leader_session_name`).
7
+ //! * Worker session is `runtime.session_name` or `team-<team.name>` (the
8
+ //! existing `lifecycle::launch::spec_session_name`).
9
+ //! * These two names MUST be disjoint by prefix
10
+ //! (`team-agent-leader-` vs `team-...`).
11
+ //!
12
+ //! This module re-exports those name constructors under a single namespace
13
+ //! and adds the topology invariant guard. No behaviour change in Step 1;
14
+ //! the guard runs as `tracing::warn!` so any drift is logged but not fatal.
15
+
16
+ use std::path::Path;
17
+
18
+ use crate::model::enums::Provider;
19
+ use crate::model::yaml::Value as YamlValue;
20
+ use crate::transport::SessionName;
21
+ use serde_json::Value as JsonValue;
22
+
23
+ /// `team-agent-leader-` — disjoint prefix for the leader session.
24
+ /// Re-exported from `leader::start::LEADER_SESSION_PREFIX` so layout-layer
25
+ /// code does not have to depend on the leader module's internals.
26
+ pub const LEADER_SESSION_PREFIX: &str = crate::leader::start::LEADER_SESSION_PREFIX;
27
+
28
+ /// Leader's dedicated tmux session name.
29
+ ///
30
+ /// Format: `team-agent-leader-<provider_wire>-<folder>-<sha1(workspace)[:8]>`.
31
+ ///
32
+ /// Re-exports `crate::leader::start::leader_session_name` so the rest of the
33
+ /// runtime can derive this name without touching the leader module. The
34
+ /// single underlying impl lives in `leader/start.rs` for now to minimise the
35
+ /// Step-1 diff; Step 2 will move it.
36
+ pub fn leader_session_name(provider: Provider, workspace: &Path) -> SessionName {
37
+ crate::leader::start::leader_session_name(provider, workspace)
38
+ }
39
+
40
+ /// Worker tmux session name derived from the team spec.
41
+ ///
42
+ /// Format: `spec.runtime.session_name` if set, else `team-<team.name>`
43
+ /// (default `team-agent`). This is a thin re-export of the existing
44
+ /// `lifecycle::launch::worker_session_name_pub` — exposed here so layout-
45
+ /// layer code has one place to ask. The spec is a `model::yaml::Value`
46
+ /// (the team spec parser's native type).
47
+ pub fn worker_session_name(spec: &YamlValue) -> SessionName {
48
+ crate::lifecycle::launch::worker_session_name_pub(spec)
49
+ }
50
+
51
+ /// True when `name` starts with the leader session prefix.
52
+ pub fn is_leader_session(name: &SessionName) -> bool {
53
+ name.as_str().starts_with(LEADER_SESSION_PREFIX)
54
+ }
55
+
56
+ /// True when `name` equals the worker session name derived from `spec`.
57
+ pub fn is_worker_session(name: &SessionName, spec: &YamlValue) -> bool {
58
+ worker_session_name(spec).as_str() == name.as_str()
59
+ }
60
+
61
+ /// A detected topology invariant violation, surfaced as a warn-level log line
62
+ /// during the incremental migration (Steps 1–9). Hard error in Step 10.
63
+ #[derive(Debug, Clone)]
64
+ pub struct TopologyViolation {
65
+ pub kind: TopologyViolationKind,
66
+ pub detail: String,
67
+ }
68
+
69
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
70
+ pub enum TopologyViolationKind {
71
+ /// Worker session name shares the leader-session prefix.
72
+ WorkerSessionNamedAsLeader,
73
+ /// A worker window inside the worker session is named `leader`.
74
+ WorkerWindowNamedLeader,
75
+ /// Two agents share the same `pane_id` in state.
76
+ AgentPaneIdCollision,
77
+ /// `leader_receiver.pane_id` matches an active agent's `pane_id`.
78
+ LeaderPaneIdCollidesWithAgent,
79
+ }
80
+
81
+ /// Assert the runtime-state topology invariants (Step 1 — `warn!` only).
82
+ ///
83
+ /// Checks:
84
+ /// 1. The worker session name does NOT start with the leader prefix.
85
+ /// 2. No agent window in worker session is literally named `leader`.
86
+ /// 3. No two agents share the same `pane_id`.
87
+ /// 4. `state.leader_receiver.pane_id` is not equal to any agent's
88
+ /// `pane_id` (E51 family — co-located lease corruption).
89
+ ///
90
+ /// Returns the list of violations without panicking. Callers may choose to
91
+ /// emit `tracing::warn!` for each (the default in Steps 1–9) or escalate.
92
+ pub fn assert_topology_invariants(state: &JsonValue, spec: &YamlValue) -> Vec<TopologyViolation> {
93
+ let mut out = Vec::new();
94
+ let worker_session = worker_session_name(spec);
95
+ if worker_session.as_str().starts_with(LEADER_SESSION_PREFIX) {
96
+ out.push(TopologyViolation {
97
+ kind: TopologyViolationKind::WorkerSessionNamedAsLeader,
98
+ detail: format!(
99
+ "worker session name `{}` starts with leader prefix `{LEADER_SESSION_PREFIX}` — \
100
+ leader and worker sessions must be disjoint",
101
+ worker_session.as_str()
102
+ ),
103
+ });
104
+ }
105
+ let agents = state
106
+ .get("agents")
107
+ .and_then(JsonValue::as_object);
108
+ if let Some(agents) = agents {
109
+ for (agent_id, agent) in agents {
110
+ let window = agent.get("window").and_then(JsonValue::as_str).unwrap_or("");
111
+ if window.eq_ignore_ascii_case("leader") {
112
+ out.push(TopologyViolation {
113
+ kind: TopologyViolationKind::WorkerWindowNamedLeader,
114
+ detail: format!(
115
+ "agent `{agent_id}` lives in window `leader` of worker session \
116
+ `{}` — workers must use `agent_id` window names",
117
+ worker_session.as_str()
118
+ ),
119
+ });
120
+ }
121
+ }
122
+ // Pane-id collision among agents.
123
+ let mut by_pane: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
124
+ for (agent_id, agent) in agents {
125
+ if let Some(pane) = agent
126
+ .get("pane_id")
127
+ .and_then(JsonValue::as_str)
128
+ .filter(|s| !s.is_empty())
129
+ {
130
+ by_pane
131
+ .entry(pane.to_string())
132
+ .or_default()
133
+ .push(agent_id.to_string());
134
+ }
135
+ }
136
+ for (pane, ids) in by_pane.iter() {
137
+ if ids.len() > 1 {
138
+ out.push(TopologyViolation {
139
+ kind: TopologyViolationKind::AgentPaneIdCollision,
140
+ detail: format!(
141
+ "pane_id `{pane}` shared by agents {ids:?} — at most one agent may bind a pane_id"
142
+ ),
143
+ });
144
+ }
145
+ }
146
+ // leader_receiver.pane_id ∉ any agent.pane_id.
147
+ let leader_pane = state
148
+ .get("leader_receiver")
149
+ .and_then(|lr| lr.get("pane_id"))
150
+ .and_then(JsonValue::as_str)
151
+ .filter(|s| !s.is_empty());
152
+ if let Some(leader_pane) = leader_pane {
153
+ for (agent_id, agent) in agents {
154
+ if agent
155
+ .get("pane_id")
156
+ .and_then(JsonValue::as_str)
157
+ .is_some_and(|p| p == leader_pane)
158
+ {
159
+ out.push(TopologyViolation {
160
+ kind: TopologyViolationKind::LeaderPaneIdCollidesWithAgent,
161
+ detail: format!(
162
+ "leader_receiver.pane_id `{leader_pane}` is also bound to \
163
+ agent `{agent_id}` — leader pane and agent pane must be disjoint"
164
+ ),
165
+ });
166
+ }
167
+ }
168
+ }
169
+ }
170
+ out
171
+ }
172
+
173
+ /// Logs each violation in `violations` to stderr under the
174
+ /// `team_agent::layout` tag. No-op when the list is empty. This is the
175
+ /// Step-1 surface — `eprintln!` matches the codebase's existing logging
176
+ /// convention (see `lifecycle::launch::eprintln!` calls). Hard error path
177
+ /// is deferred to Step 10.
178
+ pub fn log_topology_violations(violations: &[TopologyViolation]) {
179
+ for v in violations {
180
+ eprintln!(
181
+ "team_agent::layout topology_invariant_violation kind={:?} detail={}",
182
+ v.kind, v.detail
183
+ );
184
+ }
185
+ }
186
+
187
+ #[cfg(test)]
188
+ mod tests {
189
+ use super::*;
190
+ use serde_json::json;
191
+
192
+ fn spec_for(team: &str) -> YamlValue {
193
+ YamlValue::Map(vec![(
194
+ "team".to_string(),
195
+ YamlValue::Map(vec![("name".to_string(), YamlValue::Str(team.to_string()))]),
196
+ )])
197
+ }
198
+
199
+ #[test]
200
+ fn leader_and_worker_session_names_are_disjoint_by_prefix() {
201
+ let leader = leader_session_name(Provider::Claude, Path::new("/tmp/proj"));
202
+ let worker = worker_session_name(&spec_for("alpha"));
203
+ assert!(
204
+ leader.as_str().starts_with(LEADER_SESSION_PREFIX),
205
+ "leader name `{}` must start with `{LEADER_SESSION_PREFIX}`",
206
+ leader.as_str()
207
+ );
208
+ assert!(
209
+ !worker.as_str().starts_with(LEADER_SESSION_PREFIX),
210
+ "worker name `{}` must NOT start with `{LEADER_SESSION_PREFIX}`",
211
+ worker.as_str()
212
+ );
213
+ assert_ne!(leader.as_str(), worker.as_str());
214
+ assert!(is_leader_session(&leader));
215
+ assert!(!is_leader_session(&worker));
216
+ assert!(is_worker_session(&worker, &spec_for("alpha")));
217
+ assert!(!is_worker_session(&leader, &spec_for("alpha")));
218
+ }
219
+
220
+ #[test]
221
+ fn assert_topology_clean_state_returns_empty() {
222
+ let state = json!({
223
+ "agents": {
224
+ "developer": { "pane_id": "%1", "window": "developer" },
225
+ "tester": { "pane_id": "%2", "window": "tester" }
226
+ },
227
+ "leader_receiver": { "pane_id": "%0" }
228
+ });
229
+ let v = assert_topology_invariants(&state, &spec_for("alpha"));
230
+ assert!(v.is_empty(), "clean state should produce no violations; got {v:?}");
231
+ }
232
+
233
+ #[test]
234
+ fn assert_topology_flags_pane_id_collision() {
235
+ let state = json!({
236
+ "agents": {
237
+ "developer": { "pane_id": "%1", "window": "developer" },
238
+ "tester": { "pane_id": "%1", "window": "tester" }
239
+ },
240
+ "leader_receiver": { "pane_id": "%0" }
241
+ });
242
+ let v = assert_topology_invariants(&state, &spec_for("alpha"));
243
+ assert!(
244
+ v.iter().any(|x| matches!(x.kind, TopologyViolationKind::AgentPaneIdCollision)),
245
+ "must flag AgentPaneIdCollision; got {v:?}"
246
+ );
247
+ }
248
+
249
+ #[test]
250
+ fn assert_topology_flags_leader_pane_overlap_with_agent() {
251
+ let state = json!({
252
+ "agents": {
253
+ "developer": { "pane_id": "%0", "window": "developer" }
254
+ },
255
+ "leader_receiver": { "pane_id": "%0" }
256
+ });
257
+ let v = assert_topology_invariants(&state, &spec_for("alpha"));
258
+ assert!(
259
+ v.iter().any(|x| matches!(x.kind, TopologyViolationKind::LeaderPaneIdCollidesWithAgent)),
260
+ "must flag LeaderPaneIdCollidesWithAgent; got {v:?}"
261
+ );
262
+ }
263
+
264
+ #[test]
265
+ fn assert_topology_flags_worker_window_named_leader() {
266
+ let state = json!({
267
+ "agents": {
268
+ "stray": { "pane_id": "%5", "window": "leader" }
269
+ }
270
+ });
271
+ let v = assert_topology_invariants(&state, &spec_for("alpha"));
272
+ assert!(
273
+ v.iter().any(|x| matches!(x.kind, TopologyViolationKind::WorkerWindowNamedLeader)),
274
+ "must flag WorkerWindowNamedLeader; got {v:?}"
275
+ );
276
+ }
277
+ }
@@ -0,0 +1,267 @@
1
+ //! 0.3.28 layout step 3 — worker spawn env (inherit-then-strip) + worker
2
+ //! spawn cwd (YAML-only).
3
+ //!
4
+ //! Python truth source actual behavior (`providers.py:130-145` + contract
5
+ //! test `worker_spawn_env_red`): worker env INHERITS the parent
6
+ //! `team-agent` process environment (proxy / CA / PATH / random user
7
+ //! vars like `NODE_EXTRA_CA_CERTS`) just like the user typing `codex`
8
+ //! in the same terminal, then OVERLAYS the Team Agent identity
9
+ //! (`TEAM_AGENT_WORKSPACE`, `TEAM_AGENT_ID`, `TEAM_AGENT_AGENT_ID`,
10
+ //! `TEAM_AGENT_OWNER_TEAM_ID`).
11
+ //!
12
+ //! The leader's identity-specific env vars (`TEAM_AGENT_LEADER_*`,
13
+ //! `TEAM_AGENT_MACHINE_FINGERPRINT`, `TEAM_AGENT_TEAM_ID`,
14
+ //! `COPILOT_DISABLE_TERMINAL_TITLE`, `TMUX`, `TMUX_PANE`) are STRIPPED
15
+ //! to prevent E60 identity contamination.
16
+ //!
17
+ //! Pre-0.3.28-final used a WHITELIST (only specific keys pass through)
18
+ //! which broke `worker_spawn_env_red` by dropping legitimate user env
19
+ //! (`NODE_EXTRA_CA_CERTS`, etc.). Switched to inherit-then-strip per
20
+ //! leader verdict.
21
+ //!
22
+ //! Python uses `workspace` as the spawn cwd unconditionally; no
23
+ //! per-agent override field exists in the spec. Rust pre-0.3.28 honoured
24
+ //! `agent.spawn_cwd` from STATE which drifts (E56). Step 3 reads only
25
+ //! YAML `spec.agent.spawn_cwd` and falls through to `workspace`.
26
+
27
+ use std::collections::BTreeMap;
28
+ use std::path::{Path, PathBuf};
29
+
30
+ use crate::model::yaml::Value as YamlValue;
31
+
32
+ /// Env-key PREFIXES that are stripped from the inherited parent env
33
+ /// before worker spawn. These carry leader-process identity and must
34
+ /// not leak into worker provider TUIs (E60 root cause).
35
+ const STRIP_PREFIXES: &[&str] = &[
36
+ "TEAM_AGENT_LEADER_",
37
+ "TEAM_AGENT_LEADER_BYPASS",
38
+ "TEAM_AGENT_MACHINE_FINGERPRINT",
39
+ ];
40
+
41
+ /// Exact env-keys stripped from the inherited parent env.
42
+ const STRIP_EXACT: &[&str] = &[
43
+ // The leader's team_id; the worker's TEAM_AGENT_OWNER_TEAM_ID overlay
44
+ // re-supplies the relevant scope.
45
+ "TEAM_AGENT_TEAM_ID",
46
+ // Suppresses copilot's title-rewrite — must be re-injected by
47
+ // `apply_copilot_instructions_overlay` based on per-agent provider,
48
+ // not inherited from the leader's provider.
49
+ "COPILOT_DISABLE_TERMINAL_TITLE",
50
+ // TMUX env points at the leader's pane — would attach the worker to it.
51
+ "TMUX",
52
+ "TMUX_PANE",
53
+ ];
54
+
55
+ /// Build the worker spawn env per Python inherit-then-strip semantics.
56
+ ///
57
+ /// Inputs:
58
+ /// * `parent_env`: the team-agent process env (typically
59
+ /// `std::env::vars()`). The whole env is inherited then filtered.
60
+ /// * `workspace`: the team workspace root.
61
+ /// * `agent_id`: this worker's agent_id.
62
+ /// * `team_id`: optional owner team id.
63
+ ///
64
+ /// Output: a `BTreeMap` containing:
65
+ /// * All POSIX-valid shell identifier keys from `parent_env` EXCEPT
66
+ /// those matching `STRIP_PREFIXES` / `STRIP_EXACT`.
67
+ /// * Overlay: `TEAM_AGENT_WORKSPACE`, `TEAM_AGENT_ID`,
68
+ /// `TEAM_AGENT_AGENT_ID`, `TEAM_AGENT_OWNER_TEAM_ID` (when present).
69
+ ///
70
+ /// POSIX-validity filter: shell variable names must match
71
+ /// `[A-Za-z_][A-Za-z0-9_]*`. Cargo's integration-test runner exports keys
72
+ /// like `CARGO_BIN_EXE_team-agent` that contain a dash — those would
73
+ /// break `sh -lc KEY=val` assignment and are dropped.
74
+ pub fn worker_spawn_env<I>(
75
+ parent_env: I,
76
+ workspace: &Path,
77
+ agent_id: &str,
78
+ team_id: Option<&str>,
79
+ ) -> BTreeMap<String, String>
80
+ where
81
+ I: IntoIterator<Item = (String, String)>,
82
+ {
83
+ let mut env: BTreeMap<String, String> = BTreeMap::new();
84
+ for (k, v) in parent_env {
85
+ if is_stripped(&k) {
86
+ continue;
87
+ }
88
+ if !is_posix_shell_identifier(&k) {
89
+ continue;
90
+ }
91
+ env.insert(k, v);
92
+ }
93
+ env.insert(
94
+ "TEAM_AGENT_WORKSPACE".to_string(),
95
+ workspace.to_string_lossy().to_string(),
96
+ );
97
+ env.insert("TEAM_AGENT_ID".to_string(), agent_id.to_string());
98
+ env.insert("TEAM_AGENT_AGENT_ID".to_string(), agent_id.to_string());
99
+ if let Some(tid) = team_id.filter(|s| !s.is_empty()) {
100
+ env.insert("TEAM_AGENT_OWNER_TEAM_ID".to_string(), tid.to_string());
101
+ }
102
+ env
103
+ }
104
+
105
+ fn is_stripped(key: &str) -> bool {
106
+ if STRIP_EXACT.iter().any(|exact| *exact == key) {
107
+ return true;
108
+ }
109
+ if STRIP_PREFIXES.iter().any(|p| key.starts_with(p)) {
110
+ return true;
111
+ }
112
+ false
113
+ }
114
+
115
+ fn is_posix_shell_identifier(s: &str) -> bool {
116
+ let mut bytes = s.bytes();
117
+ let Some(first) = bytes.next() else { return false };
118
+ if !(first.is_ascii_alphabetic() || first == b'_') {
119
+ return false;
120
+ }
121
+ bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
122
+ }
123
+
124
+ /// Compute worker spawn cwd per Python semantics: YAML
125
+ /// `agent.spawn_cwd` if set, else `workspace`. The persisted-state
126
+ /// `agent.spawn_cwd` override (the pre-0.3.28 Rust extension that caused
127
+ /// E56 drift) is NOT consulted.
128
+ pub fn worker_spawn_cwd(spec_agent: &YamlValue, workspace: &Path) -> PathBuf {
129
+ spec_agent
130
+ .get("spawn_cwd")
131
+ .and_then(YamlValue::as_str)
132
+ .filter(|s| !s.is_empty())
133
+ .map(PathBuf::from)
134
+ .unwrap_or_else(|| workspace.to_path_buf())
135
+ }
136
+
137
+ #[cfg(test)]
138
+ mod tests {
139
+ use super::*;
140
+
141
+ fn make_parent_env(extras: &[(&str, &str)]) -> Vec<(String, String)> {
142
+ let mut env = vec![
143
+ ("PATH".to_string(), "/usr/bin:/bin".to_string()),
144
+ ("HOME".to_string(), "/Users/test".to_string()),
145
+ ("TERM".to_string(), "xterm-256color".to_string()),
146
+ ("CLAUDE_API_KEY".to_string(), "sk-test".to_string()),
147
+ ("OPENAI_API_KEY".to_string(), "sk-test".to_string()),
148
+ ("COPILOT_TOKEN".to_string(), "tok".to_string()),
149
+ // Leader identity — must be stripped.
150
+ ("TEAM_AGENT_LEADER_PROVIDER".to_string(), "claude".to_string()),
151
+ ("TEAM_AGENT_LEADER_SESSION_UUID".to_string(), "leader-uuid".to_string()),
152
+ ("TEAM_AGENT_MACHINE_FINGERPRINT".to_string(), "fp".to_string()),
153
+ ("TEAM_AGENT_TEAM_ID".to_string(), "alpha".to_string()),
154
+ ("TEAM_AGENT_LEADER_BYPASS".to_string(), "1".to_string()),
155
+ ("COPILOT_DISABLE_TERMINAL_TITLE".to_string(), "1".to_string()),
156
+ // Random noise — non-whitelisted prefix.
157
+ ("RANDOM_NOISE".to_string(), "x".to_string()),
158
+ ("TMUX".to_string(), "leader-pane".to_string()),
159
+ ("TMUX_PANE".to_string(), "%0".to_string()),
160
+ ];
161
+ for (k, v) in extras {
162
+ env.push((k.to_string(), v.to_string()));
163
+ }
164
+ env
165
+ }
166
+
167
+ #[test]
168
+ fn worker_spawn_env_strips_leader_identity_keys() {
169
+ let env = worker_spawn_env(
170
+ make_parent_env(&[]),
171
+ Path::new("/ws"),
172
+ "developer",
173
+ Some("alpha"),
174
+ );
175
+ for stripped in [
176
+ "TEAM_AGENT_LEADER_PROVIDER",
177
+ "TEAM_AGENT_LEADER_SESSION_UUID",
178
+ "TEAM_AGENT_MACHINE_FINGERPRINT",
179
+ "TEAM_AGENT_TEAM_ID",
180
+ "TEAM_AGENT_LEADER_BYPASS",
181
+ "COPILOT_DISABLE_TERMINAL_TITLE",
182
+ "TMUX",
183
+ "TMUX_PANE",
184
+ ] {
185
+ assert!(
186
+ !env.contains_key(stripped),
187
+ "worker env must not contain leader identity key `{stripped}`; env={env:?}"
188
+ );
189
+ }
190
+ }
191
+
192
+ #[test]
193
+ fn worker_spawn_env_injects_agent_workspace_and_team_keys() {
194
+ let env = worker_spawn_env(
195
+ make_parent_env(&[]),
196
+ Path::new("/ws"),
197
+ "developer",
198
+ Some("alpha"),
199
+ );
200
+ assert_eq!(env.get("TEAM_AGENT_ID").map(String::as_str), Some("developer"));
201
+ assert_eq!(env.get("TEAM_AGENT_AGENT_ID").map(String::as_str), Some("developer"));
202
+ assert_eq!(env.get("TEAM_AGENT_WORKSPACE").map(String::as_str), Some("/ws"));
203
+ assert_eq!(env.get("TEAM_AGENT_OWNER_TEAM_ID").map(String::as_str), Some("alpha"));
204
+ }
205
+
206
+ #[test]
207
+ fn worker_spawn_env_inherits_parent_env_minus_strip_list() {
208
+ let env = worker_spawn_env(
209
+ make_parent_env(&[]),
210
+ Path::new("/ws"),
211
+ "developer",
212
+ None,
213
+ );
214
+ // Inherit-then-strip: every parent key passes through EXCEPT the
215
+ // strip list. PATH-like + provider creds + random user vars all
216
+ // present.
217
+ assert!(env.contains_key("PATH"));
218
+ assert!(env.contains_key("HOME"));
219
+ assert!(env.contains_key("TERM"));
220
+ assert!(env.contains_key("CLAUDE_API_KEY"));
221
+ assert!(env.contains_key("OPENAI_API_KEY"));
222
+ // Random user env (e.g. NODE_EXTRA_CA_CERTS, TA_SPAWN_ENV_CANARY)
223
+ // MUST pass through — this was the worker_spawn_env_red contract
224
+ // that the pre-final whitelist approach broke.
225
+ assert!(
226
+ env.contains_key("RANDOM_NOISE"),
227
+ "0.3.28 final: worker env inherits parent — random user vars \
228
+ must pass through (NODE_EXTRA_CA_CERTS / TA_SPAWN_ENV_CANARY \
229
+ contract). Got env={env:?}"
230
+ );
231
+ }
232
+
233
+ #[test]
234
+ fn worker_spawn_env_drops_posix_invalid_keys() {
235
+ let parent = make_parent_env(&[("CARGO_BIN_EXE_team-agent", "/bin/x")]);
236
+ let env = worker_spawn_env(parent, Path::new("/ws"), "developer", None);
237
+ assert!(!env.contains_key("CARGO_BIN_EXE_team-agent"));
238
+ }
239
+
240
+ #[test]
241
+ fn worker_spawn_cwd_returns_workspace_when_no_spec_override() {
242
+ // YAML agent with no spawn_cwd field.
243
+ let agent = YamlValue::Map(vec![("id".to_string(), YamlValue::Str("developer".to_string()))]);
244
+ assert_eq!(worker_spawn_cwd(&agent, Path::new("/ws")), PathBuf::from("/ws"));
245
+ }
246
+
247
+ #[test]
248
+ fn worker_spawn_cwd_returns_yaml_override_when_present() {
249
+ let agent = YamlValue::Map(vec![
250
+ ("id".to_string(), YamlValue::Str("developer".to_string())),
251
+ ("spawn_cwd".to_string(), YamlValue::Str("/explicit/cwd".to_string())),
252
+ ]);
253
+ assert_eq!(
254
+ worker_spawn_cwd(&agent, Path::new("/ws")),
255
+ PathBuf::from("/explicit/cwd")
256
+ );
257
+ }
258
+
259
+ #[test]
260
+ fn worker_spawn_cwd_returns_workspace_when_spec_override_is_empty() {
261
+ let agent = YamlValue::Map(vec![
262
+ ("id".to_string(), YamlValue::Str("developer".to_string())),
263
+ ("spawn_cwd".to_string(), YamlValue::Str("".to_string())),
264
+ ]);
265
+ assert_eq!(worker_spawn_cwd(&agent, Path::new("/ws")), PathBuf::from("/ws"));
266
+ }
267
+ }
@@ -0,0 +1,35 @@
1
+ //! 0.3.28 layout step 2 — small provider-wire helper for layout-layer window naming.
2
+ //!
3
+ //! Mirrors `leader::helpers::provider_wire` so the layout module does not
4
+ //! depend on the leader module's private helpers. Single source of truth
5
+ //! is `provider_command_name` from `crate::provider::adapter` (the
6
+ //! authoritative provider→string mapping).
7
+
8
+ use crate::model::enums::Provider;
9
+
10
+ /// Window name used inside the leader session, derived from the provider's
11
+ /// wire identifier (e.g. `claude`, `codex`, `copilot`, `fake`). Mirrors
12
+ /// Python's `leader_window_name = provider` (see
13
+ /// `runtime/0.2.11/src/team_agent/leader/__init__.py:114-131`).
14
+ pub fn provider_window_name(provider: Provider) -> &'static str {
15
+ match provider {
16
+ Provider::Claude | Provider::ClaudeCode => "claude",
17
+ Provider::Codex => "codex",
18
+ Provider::Copilot => "copilot",
19
+ Provider::GeminiCli => "gemini",
20
+ Provider::Fake => "fake",
21
+ }
22
+ }
23
+
24
+ #[cfg(test)]
25
+ mod tests {
26
+ use super::*;
27
+
28
+ #[test]
29
+ fn provider_window_names_are_lowercase_wire_strings() {
30
+ assert_eq!(provider_window_name(Provider::Claude), "claude");
31
+ assert_eq!(provider_window_name(Provider::ClaudeCode), "claude");
32
+ assert_eq!(provider_window_name(Provider::Codex), "codex");
33
+ assert_eq!(provider_window_name(Provider::Copilot), "copilot");
34
+ }
35
+ }
@@ -53,12 +53,18 @@ pub fn leader_start_plan(
53
53
  let state = crate::state::persist::load_runtime_state(workspace).ok();
54
54
  let identity = leader_identity_context(workspace, None, state.as_ref())?;
55
55
  let external_path = external_leader || attach_existing || attach_session.is_some();
56
+ // 0.3.28 Step 2: managed mode now uses the SAME dedicated leader session
57
+ // as the external path (`team-agent-leader-<provider>-<folder>-<sha1[:8]>`)
58
+ // — Python parity. Pre-0.3.28 the managed branch used
59
+ // `managed_team_session_name(identity) = team-<team_id>` which is the
60
+ // worker session — that co-location is the structural root of
61
+ // E49/E51/E53/E57-3/E60.
56
62
  let session_name = if external_path {
57
63
  attach_session
58
64
  .cloned()
59
65
  .or_else(|| Some(leader_session_name(provider, workspace)))
60
66
  } else {
61
- Some(managed_team_session_name(&identity))
67
+ Some(leader_session_name(provider, workspace))
62
68
  };
63
69
  let in_tmux = std::env::var_os("TMUX").is_some();
64
70
  if !in_tmux || !external_path {
@@ -107,8 +113,14 @@ pub fn leader_start_plan(
107
113
  session_name,
108
114
  argv,
109
115
  provider_argv,
116
+ // 0.3.28 Step 2: leader window inside the dedicated leader session is
117
+ // named after the provider wire (e.g. `claude`, `codex`, `copilot`),
118
+ // never the literal string `leader`. Python parity (see
119
+ // `leader/__init__.py:114-131`). This eliminates the `WorkerWindowNamedLeader`
120
+ // topology violation surface — the worker session never has a window
121
+ // named `leader` either, because the leader session is disjoint.
110
122
  leader_window: (mode == LeaderStartMode::ManagedTmuxClient)
111
- .then(|| WindowName::new("leader")),
123
+ .then(|| WindowName::new(provider_wire(provider))),
112
124
  is_external_leader: external_path,
113
125
  leader_env: plan_env,
114
126
  identity: Some(identity),
@@ -265,7 +277,7 @@ fn start_argv(
265
277
  let Some(session) = session_name else {
266
278
  return Err(LeaderError::Start("managed leader session missing".to_string()));
267
279
  };
268
- managed_client_argv(workspace, session)
280
+ managed_client_argv(workspace, session, provider)
269
281
  }
270
282
  LeaderStartMode::AttachExisting => {
271
283
  let Some(session) = session_name else {
@@ -327,17 +339,21 @@ fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = St
327
339
  .cloned()
328
340
  }
329
341
 
330
- fn managed_team_session_name(identity: &LeaderIdentity) -> SessionName {
331
- let team = identity.team_id.as_str();
332
- if team.starts_with("team-") {
333
- SessionName::new(team.to_string())
334
- } else {
335
- SessionName::new(format!("team-{team}"))
336
- }
337
- }
342
+ // 0.3.28 Step 2: `managed_team_session_name` deleted. Both managed and
343
+ // external paths now compute the dedicated leader session via
344
+ // `leader_session_name(provider, workspace)` directly. The old function
345
+ // returned `team-<team_id>` which is the WORKER session — the structural
346
+ // root of E49/E51/E53/E57-3/E60.
338
347
 
339
- fn managed_client_argv(workspace: &Path, session: &SessionName) -> Result<Vec<String>, LeaderError> {
340
- let target = format!("{}:leader", session.as_str());
348
+ fn managed_client_argv(
349
+ workspace: &Path,
350
+ session: &SessionName,
351
+ provider: Provider,
352
+ ) -> Result<Vec<String>, LeaderError> {
353
+ // 0.3.28 Step 2: leader window inside the dedicated leader session is
354
+ // named after `provider_wire(provider)` (e.g. `claude`, `codex`, `fake`),
355
+ // never the literal `leader`. Pre-0.3.28 this hardcoded `:leader`.
356
+ let target = format!("{}:{}", session.as_str(), provider_wire(provider));
341
357
  let argv = if std::env::var_os("TMUX").is_some() {
342
358
  vec![
343
359
  "tmux".to_string(),