@team-agent/installer 0.5.109 → 0.5.111

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 (34) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +33 -0
  4. package/README.zh.md +30 -0
  5. package/crates/team-agent/src/cli/adapters.rs +234 -73
  6. package/crates/team-agent/src/cli/emit.rs +494 -201
  7. package/crates/team-agent/src/cli/leader.rs +4 -9
  8. package/crates/team-agent/src/cli/leaders.rs +90 -29
  9. package/crates/team-agent/src/cli/mod.rs +348 -161
  10. package/crates/team-agent/src/cli/models.rs +80 -24
  11. package/crates/team-agent/src/cli/named_address.rs +114 -25
  12. package/crates/team-agent/src/cli/route.rs +212 -0
  13. package/crates/team-agent/src/cli/send/presentation.rs +60 -41
  14. package/crates/team-agent/src/cli/send/resolve.rs +25 -18
  15. package/crates/team-agent/src/cli/send.rs +4 -4
  16. package/crates/team-agent/src/cli/spec.rs +56 -106
  17. package/crates/team-agent/src/cli/tests/base.rs +189 -82
  18. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -2
  19. package/crates/team-agent/src/cli/tests/named_address.rs +19 -22
  20. package/crates/team-agent/src/cli/tests/status_send.rs +15 -10
  21. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +2 -2
  22. package/crates/team-agent/src/cli/types.rs +22 -13
  23. package/crates/team-agent/src/leader/registry.rs +3 -3
  24. package/crates/team-agent/src/leader/start.rs +5 -1
  25. package/crates/team-agent/src/lifecycle/launch/spawn.rs +2 -0
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +2 -0
  27. package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +1457 -0
  28. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +4 -9
  29. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -1
  30. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  31. package/crates/team-agent/src/provider/argv_route.rs +409 -0
  32. package/crates/team-agent/src/provider/mod.rs +3 -2
  33. package/package.json +4 -4
  34. package/skills/team-agent/SKILL.md +32 -0
@@ -0,0 +1,1457 @@
1
+ //! Independent Issue #283 lifecycle properties, using only frozen-baseline seams.
2
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
3
+ #![cfg(unix)]
4
+ #[path = "../../../tests/support/hermetic.rs"]
5
+ mod hermetic;
6
+ use crate::leader::{LeaderStartMode, LeaderStartPlan};
7
+ use crate::lifecycle::launch::{
8
+ add_agent_with_transport, fork_agent_with_transport, quick_start_with_transport_in_workspace,
9
+ };
10
+ use crate::lifecycle::{
11
+ launch_with_transport_in_workspace, reset_agent_with_transport, restart_with_transport,
12
+ start_agent_with_transport,
13
+ };
14
+ use crate::model::enums::Provider;
15
+ use crate::model::ids::AgentId;
16
+ use crate::state::persist::{load_runtime_state, save_runtime_state};
17
+ use crate::transport::test_support::OfflineTransport;
18
+ use crate::transport::{PaneId, PaneInfo, SessionName, WindowName};
19
+ use serde_json::{json, Value};
20
+ use serial_test::serial;
21
+ use std::collections::BTreeMap;
22
+ use std::fs;
23
+ use std::os::unix::fs::PermissionsExt;
24
+ use std::path::{Path, PathBuf};
25
+ use std::process::{Command, Stdio};
26
+ use std::time::{Duration, Instant};
27
+
28
+ const SWITCH: &str = "TEAM_AGENT_CLI_ARGV_ROUTING";
29
+ const CANONICAL: &[(&str, Provider)] = &[
30
+ ("claude", Provider::Claude),
31
+ ("claude", Provider::ClaudeCode),
32
+ ("codex", Provider::Codex),
33
+ ("copilot", Provider::Copilot),
34
+ ("gemini_cli", Provider::GeminiCli),
35
+ ("grok", Provider::Grok),
36
+ ("cursor_agent", Provider::CursorAgent),
37
+ ("pi", Provider::Pi),
38
+ ];
39
+ struct Fixture {
40
+ guards: Vec<hermetic::EnvOverride>,
41
+ env: hermetic::HermeticTestEnv,
42
+ ws: PathBuf,
43
+ team: PathBuf,
44
+ bin: PathBuf,
45
+ probes: PathBuf,
46
+ }
47
+ impl Fixture {
48
+ fn new(tag: &str) -> Self {
49
+ let env = hermetic::HermeticTestEnv::enter(tag);
50
+ let ws = env.workspace("workspace");
51
+ let team = ws.join("roles");
52
+ fs::create_dir_all(team.join("agents")).unwrap();
53
+ let bin = env.root().join("bin");
54
+ fs::create_dir(&bin).unwrap();
55
+ let probes = env.root().join("probe-argv");
56
+ let native = env.root().join("native-argv");
57
+ // Synthetic noncredential metadata; no user auth store or login is read.
58
+ fs::create_dir_all(env.home().join(".grok")).unwrap();
59
+ fs::write(
60
+ env.home().join(".grok/auth.json"),
61
+ r#"{"fixture_only":true}"#,
62
+ )
63
+ .unwrap();
64
+ let script = format!("#!/bin/sh\ncase \"$1\" in\n--version) printf '%s\\n' \"$*\" >> '{}'; echo 0.87.1; exit 0;;\n--list-models) printf '%s\\n' \"$*\" >> '{}'; printf 'provider model\\nteam-agent qwen3.8-27b\\n'; exit 0;;\nplugin|mcp|auth|list) printf '%s\\n' \"$*\" >> '{}'; exit 0;;\nesac\nprintf '%s\\0' \"$@\" > '{}'\n/bin/kill -TERM \"$PPID\"\n", probes.display(), probes.display(), probes.display(), native.display());
65
+ for name in ["claude", "codex", "copilot", "gemini", "grok", "agent"] {
66
+ executable(&bin.join(name), &script);
67
+ }
68
+ let real_pi = env.root().join("pi-real");
69
+ executable(&real_pi, &script);
70
+ std::os::unix::fs::symlink(&real_pi, bin.join("pi")).unwrap();
71
+ executable(&bin.join("tmux"), "#!/bin/sh\ncase \"$*\" in\n*-V*) echo 'tmux 3.4';;\n*has-session*) exit 1;;\n*list-sessions*|*list-windows*|*list-panes*) exit 0;;\n*) exit 0;;\nesac\n");
72
+ let guards = vec![
73
+ env.with_env("PATH", &format!("{}:/usr/bin:/bin", bin.display())),
74
+ env.with_env(SWITCH, "off"),
75
+ env.with_env("GROK_FOLDER_TRUST", "0"),
76
+ env.with_env(
77
+ "TEAM_AGENT_TEST_PROCESS_ANCESTRY_ARGV_JSON",
78
+ "[\"/bin/zsh\"]",
79
+ ),
80
+ env.with_env("TEAM_AGENT_RESTART_SESSION_CAPTURE_DEADLINE_MS", "25"),
81
+ env.with_env("TEAM_AGENT_RESTART_SESSION_CAPTURE_POLL_MS", "5"),
82
+ ];
83
+ seed_coordinator(&ws);
84
+ Self {
85
+ guards,
86
+ env,
87
+ ws,
88
+ team,
89
+ bin,
90
+ probes,
91
+ }
92
+ }
93
+ fn config(&self) -> PathBuf {
94
+ self.env.home().join(".team-agent/argv-routing.json")
95
+ }
96
+ fn seed(&self, enabled: bool, providers: Value) {
97
+ self.raw(&json!({"schema_version":1,"enabled":enabled,"providers":providers}).to_string());
98
+ }
99
+ fn raw(&self, raw: &str) {
100
+ fs::write(self.config(), raw).unwrap();
101
+ }
102
+ fn switch(&self, value: Option<&str>) {
103
+ unsafe {
104
+ if let Some(v) = value {
105
+ std::env::set_var(SWITCH, v);
106
+ } else {
107
+ std::env::remove_var(SWITCH);
108
+ }
109
+ }
110
+ }
111
+ fn spec(&self, provider: &str, ids: &[&str]) -> PathBuf {
112
+ fs::write(self.team.join("TEAM.md"), "---\nname: argvteam\nobjective: Literal argv route fixture.\n---\n\nTeam.\n").unwrap();
113
+ for id in ids {
114
+ fs::write(
115
+ self.team.join(format!("agents/{id}.md")),
116
+ role(provider, id),
117
+ )
118
+ .unwrap();
119
+ }
120
+ let spec = crate::compiler::compile_team(&self.team).unwrap();
121
+ let path = self.team.join("team.spec.yaml");
122
+ fs::write(&path, crate::model::yaml::dumps(&spec)).unwrap();
123
+ path
124
+ }
125
+ fn cold(&self, provider: &str) -> Vec<String> {
126
+ let spec = self.spec(provider, &["worker"]);
127
+ let t = offline();
128
+ // Each property sample is a new fixture-owned cold generation, not an
129
+ // unauthorized overwrite of a live topology. No real pane/process exists.
130
+ let state_path = crate::state::persist::runtime_state_path(&self.ws);
131
+ fs::create_dir_all(state_path.parent().unwrap()).unwrap();
132
+ // Missing files can legitimately recover from the process-local state
133
+ // cache. A literal empty fixture snapshot starts a new cold generation.
134
+ fs::write(state_path, "{}").unwrap();
135
+ let result = launch_with_transport_in_workspace(&self.ws, &spec, false, false, true, &t);
136
+ assert!(
137
+ result.is_ok(),
138
+ "fixture library cold boundary must reach native plan: {result:?}"
139
+ );
140
+ let spawns = t.spawn_records();
141
+ assert_eq!(spawns.len(), 1);
142
+ spawns[0].1.clone()
143
+ }
144
+ fn leader(
145
+ &self,
146
+ provider: Provider,
147
+ external: bool,
148
+ attach: bool,
149
+ ) -> Result<LeaderStartPlan, crate::leader::LeaderError> {
150
+ crate::leader::start::leader_start_plan_after_ambient_authority_check(
151
+ provider,
152
+ &[],
153
+ &self.ws,
154
+ attach,
155
+ attach,
156
+ None,
157
+ external,
158
+ )
159
+ }
160
+ fn start_team(&self, provider: &str, ids: &[&str]) -> OfflineTransport {
161
+ self.spec(provider, ids);
162
+ let t = offline();
163
+ let report = quick_start_with_transport_in_workspace(
164
+ &self.ws,
165
+ &self.team,
166
+ None,
167
+ true,
168
+ Some("argvteam"),
169
+ &t,
170
+ )
171
+ .unwrap();
172
+ assert!(
173
+ matches!(report, crate::lifecycle::QuickStartReport::Ready { .. }),
174
+ "setup must produce real spawn: {report:?}"
175
+ );
176
+ assert_eq!(t.spawn_records().len(), ids.len());
177
+ t
178
+ }
179
+ fn mutate_agent(&self, id: &str, f: impl FnOnce(&mut Value)) {
180
+ let selected =
181
+ crate::state::projection::select_runtime_state(&self.ws, Some("argvteam")).unwrap();
182
+ let mut state = selected;
183
+ f(&mut state["agents"][id]);
184
+ crate::state::projection::save_team_scoped_state_with_lifecycle_topology_authority(
185
+ &self.ws,
186
+ &state,
187
+ "argvteam",
188
+ &[id],
189
+ )
190
+ .unwrap();
191
+ }
192
+ fn native_file(&self) -> PathBuf {
193
+ self.env.root().join("native-argv")
194
+ }
195
+ }
196
+ // Field guards restore changed environment before HermeticTestEnv removes its owned root.
197
+ impl Drop for Fixture {
198
+ fn drop(&mut self) {
199
+ self.guards.clear();
200
+ }
201
+ }
202
+ fn offline() -> OfflineTransport {
203
+ let mut t = OfflineTransport::new();
204
+ for index in 0..=8 {
205
+ t = t.with_capture_for_pane(
206
+ format!("%{index}"),
207
+ "Claude Code\n> \nOpenAI Codex\ncodex>\n❯\n / commands · ? help\n",
208
+ );
209
+ }
210
+ t
211
+ }
212
+ fn executable(path: &Path, script: &str) {
213
+ fs::write(path, script).unwrap();
214
+ fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
215
+ }
216
+ fn role(provider: &str, id: &str) -> String {
217
+ let model = if provider == "pi" {
218
+ "team-agent/qwen3.8-27b"
219
+ } else {
220
+ "gpt-5.5"
221
+ };
222
+ format!("---\nname: {id}\nrole: {id} Worker\nprovider: {provider}\nmodel: {model}\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n---\n\nLiteral route worker.\n")
223
+ }
224
+ fn seed_coordinator(ws: &Path) {
225
+ let workspace = crate::coordinator::WorkspacePath::new(ws.to_path_buf());
226
+ let pid = crate::coordinator::Pid::new(std::process::id());
227
+ fs::create_dir_all(crate::model::paths::runtime_dir(ws)).unwrap();
228
+ crate::coordinator::write_coordinator_metadata(
229
+ &workspace,
230
+ pid,
231
+ crate::coordinator::MetadataSource::Boot,
232
+ )
233
+ .unwrap();
234
+ fs::write(
235
+ crate::coordinator::coordinator_pid_path(&workspace),
236
+ pid.to_string(),
237
+ )
238
+ .unwrap();
239
+ }
240
+ fn normalized(argv: &[String]) -> Vec<String> {
241
+ let mut result = argv.to_vec();
242
+ for i in 1..result.len() {
243
+ if result[i - 1] == "--session-id" {
244
+ result[i] = "<baseline-dynamic-session-id>".into();
245
+ }
246
+ }
247
+ result
248
+ }
249
+ fn injected(base: &[String], route: &[String], actual: &[String]) {
250
+ let mut expected = base[..1].to_vec();
251
+ expected.extend_from_slice(route);
252
+ expected.extend_from_slice(&base[1..]);
253
+ assert_eq!(
254
+ normalized(actual),
255
+ normalized(&expected),
256
+ "R must be inserted once immediately after executable; native tail unchanged"
257
+ );
258
+ }
259
+ fn assert_route(actual: &[String], route: &[&str]) {
260
+ assert!(
261
+ actual.len() > route.len(),
262
+ "native executable and tail must exist"
263
+ );
264
+ assert_eq!(
265
+ &actual[1..=route.len()],
266
+ route,
267
+ "route missing/incorrect at final native spawn: {actual:?}"
268
+ );
269
+ for token in route {
270
+ assert_eq!(
271
+ actual.iter().filter(|v| v.as_str() == *token).count(),
272
+ 1,
273
+ "duplicate route token {token}"
274
+ );
275
+ }
276
+ }
277
+ fn same_leader_env(actual: &BTreeMap<String, String>, base: &BTreeMap<String, String>) {
278
+ let mut actual = actual.clone();
279
+ let mut base = base.clone();
280
+ // This intentional caller-input difference is not product env injection.
281
+ actual.remove(SWITCH);
282
+ base.remove(SWITCH);
283
+ assert!(
284
+ actual == base,
285
+ "native env changed beyond the caller's routing override"
286
+ );
287
+ }
288
+ fn live_pane(ws: &Path) -> PaneInfo {
289
+ let state = crate::state::projection::select_runtime_state(ws, Some("argvteam")).unwrap();
290
+ PaneInfo {
291
+ pane_id: PaneId::new("%old"),
292
+ session: SessionName::new(state["session_name"].as_str().unwrap()),
293
+ window_index: None,
294
+ window_name: Some(WindowName::new("worker")),
295
+ pane_index: None,
296
+ tty: None,
297
+ current_command: Some("codex".into()),
298
+ current_path: None,
299
+ active: true,
300
+ pane_pid: None,
301
+ leader_env: BTreeMap::new(),
302
+ }
303
+ }
304
+
305
+ #[test]
306
+ #[serial(env)]
307
+ fn a01_default_off_matches_explicit_off_at_cold_common_and_leader_boundaries() {
308
+ let f = Fixture::new("argv-a01");
309
+ f.switch(None);
310
+ let cold = f.cold("codex");
311
+ assert!(!f.config().exists());
312
+ unsafe {
313
+ std::env::set_var("TMUX", "fixture,1,0");
314
+ }
315
+ let leader = f.leader(Provider::Codex, true, false).unwrap();
316
+ f.seed(true, json!({"codex":["must-not-appear"]}));
317
+ f.switch(Some("off"));
318
+ assert_eq!(normalized(&f.cold("codex")), normalized(&cold));
319
+ let off_leader = f.leader(Provider::Codex, true, false).unwrap();
320
+ assert_eq!(off_leader.argv, leader.argv);
321
+ same_leader_env(&off_leader.leader_env, &leader.leader_env);
322
+ assert_eq!(off_leader.workspace, leader.workspace);
323
+ assert_eq!(off_leader.identity, leader.identity);
324
+ unsafe {
325
+ std::env::remove_var("TMUX");
326
+ }
327
+ drop(f);
328
+ let f = Fixture::new("argv-a01-common");
329
+ f.switch(None);
330
+ f.start_team("codex", &["worker"]);
331
+ let baseline = offline();
332
+ start_agent_with_transport(
333
+ &f.ws,
334
+ &AgentId::new("worker"),
335
+ true,
336
+ false,
337
+ false,
338
+ Some("argvteam"),
339
+ &baseline,
340
+ )
341
+ .unwrap();
342
+ f.seed(true, json!({"codex":["must-not-appear"]}));
343
+ f.switch(Some("off"));
344
+ let t = offline();
345
+ start_agent_with_transport(
346
+ &f.ws,
347
+ &AgentId::new("worker"),
348
+ true,
349
+ false,
350
+ false,
351
+ Some("argvteam"),
352
+ &t,
353
+ )
354
+ .unwrap();
355
+ assert_eq!(
356
+ normalized(&t.spawn_records()[0].1),
357
+ normalized(&baseline.spawn_records()[0].1)
358
+ );
359
+ assert_eq!(t.spawn_cwd_records(), baseline.spawn_cwd_records());
360
+ assert!(!t.spawn_records()[0]
361
+ .1
362
+ .iter()
363
+ .any(|v| v == "must-not-appear"));
364
+ assert!(!f.config().with_file_name("argv-routing.lock").exists());
365
+ }
366
+
367
+ #[test]
368
+ #[serial(env)]
369
+ fn a02_off_invalid_and_unopted_bad_json_preserve_native_startup() {
370
+ let f = Fixture::new("argv-a02");
371
+ let base = f.cold("codex");
372
+ for raw in [
373
+ "bad JSON",
374
+ r#"{"enabled":false,"providers":"bad"}"#,
375
+ r#"{"enabled":true,"providers":{"codex":[0]}}"#,
376
+ ] {
377
+ f.raw(raw);
378
+ for value in [Some("off"), Some(" 0 "), Some(""), Some("bogus")] {
379
+ f.switch(value);
380
+ assert_eq!(normalized(&f.cold("codex")), normalized(&base));
381
+ }
382
+ }
383
+ for raw in [
384
+ "bad JSON",
385
+ r#"{"enabled":false,"providers":"bad"}"#,
386
+ r#"{"enabled":"unknown"}"#,
387
+ ] {
388
+ f.raw(raw);
389
+ f.switch(None);
390
+ assert_eq!(normalized(&f.cold("codex")), normalized(&base));
391
+ }
392
+ }
393
+
394
+ #[test]
395
+ #[serial(env)]
396
+ fn a02_forced_off_proves_no_config_open_with_fifo_and_bounded_child() {
397
+ let f = Fixture::new("argv-fifo");
398
+ let path = std::ffi::CString::new(f.config().as_os_str().as_encoded_bytes()).unwrap();
399
+ assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
400
+ let mut child = Command::new(std::env::current_exe().unwrap())
401
+ .args([
402
+ "--exact",
403
+ "lifecycle::tests::cli_argv_routing_red::off_fifo_child",
404
+ "--ignored",
405
+ ])
406
+ .env(SWITCH, "off")
407
+ .env("ARGV_RED_WORKSPACE", &f.ws)
408
+ .stdout(Stdio::null())
409
+ .stderr(Stdio::null())
410
+ .spawn()
411
+ .unwrap();
412
+ let start = Instant::now();
413
+ loop {
414
+ if let Some(status) = child.try_wait().unwrap() {
415
+ assert!(
416
+ status.success(),
417
+ "OFF child plan must succeed without reading FIFO"
418
+ );
419
+ break;
420
+ }
421
+ if start.elapsed() > Duration::from_secs(3) {
422
+ child.kill().unwrap();
423
+ child.wait().unwrap();
424
+ panic!("OFF opened FIFO instead of short-circuiting before configuration read");
425
+ }
426
+ std::thread::sleep(Duration::from_millis(10));
427
+ }
428
+ }
429
+ #[test]
430
+ #[ignore = "invoked only by bounded no-config-open property"]
431
+ fn off_fifo_child() {
432
+ let ws = PathBuf::from(std::env::var_os("ARGV_RED_WORKSPACE").expect("subprocess-only helper"));
433
+ crate::leader::start::leader_start_plan_after_ambient_authority_check(
434
+ Provider::Codex,
435
+ &[],
436
+ &ws,
437
+ false,
438
+ false,
439
+ None,
440
+ false,
441
+ )
442
+ .unwrap();
443
+ }
444
+
445
+ #[test]
446
+ #[serial(env)]
447
+ fn a03_persisted_enabled_and_env_override_apply_at_native_cold_boundary() {
448
+ let f = Fixture::new("argv-a03");
449
+ let base = f.cold("codex");
450
+ for (persisted, env, should_route) in [
451
+ (true, None, true),
452
+ (false, None, false),
453
+ (false, Some("on"), true),
454
+ (true, Some("false"), false),
455
+ ] {
456
+ f.seed(persisted, json!({"codex":["route-priority"]}));
457
+ f.switch(env);
458
+ let argv = f.cold("codex");
459
+ injected(
460
+ &base,
461
+ &if should_route {
462
+ vec!["route-priority".into()]
463
+ } else {
464
+ vec![]
465
+ },
466
+ &argv,
467
+ );
468
+ }
469
+ }
470
+
471
+ #[test]
472
+ #[serial(env)]
473
+ fn a05_generated_literal_tokens_roundtrip_cli_json_native_and_posix_wrappers() {
474
+ let f = Fixture::new("argv-literal");
475
+ let base = f.cold("pi");
476
+ let sentinel = f.env.root().join("INJECTION-MUST-NOT-RUN");
477
+ for seed in 0..16 {
478
+ let route = vec![
479
+ "".into(),
480
+ format!(" 路由{seed} 🚀 "),
481
+ "\"x\" 'y'\nline".into(),
482
+ format!(
483
+ "; touch {}; $(touch {}); `touch {}`",
484
+ sentinel.display(),
485
+ sentinel.display(),
486
+ sentinel.display()
487
+ ),
488
+ "{workspace}".into(),
489
+ "--help".into(),
490
+ "--json".into(),
491
+ "--no-display".into(),
492
+ "--".into(),
493
+ ];
494
+ let mut cmd = Command::new(super::test_binary_path());
495
+ cmd.current_dir(&f.ws)
496
+ .args(["route", "set", "pi", "--json", "--"])
497
+ .args(&route)
498
+ .env(SWITCH, "off");
499
+ let out = cmd.output().unwrap();
500
+ assert!(
501
+ out.status.success(),
502
+ "CLI literal property requires successful storage: {:?}",
503
+ String::from_utf8_lossy(&out.stderr)
504
+ );
505
+ let persisted: Value = serde_json::from_slice(&fs::read(f.config()).unwrap()).unwrap();
506
+ assert_eq!(persisted["providers"]["pi"], json!(route));
507
+ f.switch(Some("on"));
508
+ let actual = f.cold("pi");
509
+ injected(&base, &route, &actual);
510
+ let mut recorded = actual.clone();
511
+ recorded[0] = f.bin.join("pi").to_string_lossy().into_owned();
512
+ for leader in [false, true] {
513
+ let line = if leader {
514
+ crate::tmux_backend::leader_shell_wrapper_command(
515
+ &recorded,
516
+ &f.ws,
517
+ &BTreeMap::new(),
518
+ &[],
519
+ "pi",
520
+ )
521
+ } else {
522
+ crate::tmux_backend::worker_shell_wrapper_command(
523
+ &recorded,
524
+ &f.ws,
525
+ &BTreeMap::new(),
526
+ &[],
527
+ "pi",
528
+ )
529
+ };
530
+ // Recorder terminates its exact parent shell after capturing argv; no inert-tail orphan.
531
+ let status = Command::new("/bin/sh")
532
+ .args(["-c", &line])
533
+ .stdout(Stdio::null())
534
+ .stderr(Stdio::null())
535
+ .status()
536
+ .unwrap();
537
+ assert!(!status.success());
538
+ let bytes = fs::read(f.native_file()).unwrap();
539
+ let args = bytes
540
+ .split(|b| *b == 0)
541
+ .take(recorded.len() - 1)
542
+ .map(|v| String::from_utf8(v.to_vec()).unwrap())
543
+ .collect::<Vec<_>>();
544
+ assert_eq!(args, recorded[1..]);
545
+ assert!(!sentinel.exists());
546
+ }
547
+ f.switch(Some("off"));
548
+ }
549
+ }
550
+
551
+ fn provider_property(key: &str, provider: Provider) {
552
+ let f = Fixture::new("argv-provider");
553
+ let _leader = f.env.with_env("TEAM_AGENT_LEADER_PROVIDER", "pi");
554
+ let _caller = f.env.with_env("TEAM_AGENT_CALLER_PROVIDER", "pi");
555
+ let wire = crate::provider::wire::provider_wire(provider);
556
+ let base = f.cold(wire);
557
+ let providers = CANONICAL
558
+ .iter()
559
+ .map(|(k, _)| ((*k).to_string(), json!([format!("route-only-{k}")])))
560
+ .collect::<serde_json::Map<_, _>>();
561
+ f.seed(true, Value::Object(providers));
562
+ f.switch(None);
563
+ let actual = f.cold(wire);
564
+ f.seed(true, json!({"pi":["pi-must-not-leak"]}));
565
+ if provider != Provider::Pi {
566
+ assert_eq!(normalized(&f.cold(wire)), normalized(&base));
567
+ }
568
+ injected(&base, &[format!("route-only-{key}")], &actual);
569
+ assert_eq!(
570
+ actual
571
+ .iter()
572
+ .filter(|v| v.starts_with("route-only-"))
573
+ .count(),
574
+ 1
575
+ );
576
+ }
577
+ macro_rules! provider_tests { ($($name:ident => $key:literal, $provider:ident;)*) => { $(#[test] #[serial(env)] fn $name() { provider_property($key, Provider::$provider); })* }; }
578
+ provider_tests! {
579
+ a06_claude => "claude", Claude;
580
+ a06_claude_code => "claude", ClaudeCode;
581
+ a06_codex => "codex", Codex;
582
+ a06_copilot => "copilot", Copilot;
583
+ a06_gemini_cli => "gemini_cli", GeminiCli;
584
+ a06_grok => "grok", Grok;
585
+ a06_cursor_agent => "cursor_agent", CursorAgent;
586
+ a06_pi => "pi", Pi;
587
+ }
588
+
589
+ #[test]
590
+ #[serial(env)]
591
+ fn a06_fake_worker_is_outside_native_router_even_under_invalid_optin() {
592
+ let f = Fixture::new("argv-fake");
593
+ let base = f.cold("fake");
594
+ f.raw("invalid JSON");
595
+ f.switch(Some("on"));
596
+ assert_eq!(f.cold("fake"), base);
597
+ assert!(base.iter().any(|v| v == "fake-worker"));
598
+ }
599
+
600
+ #[test]
601
+ #[serial(env)]
602
+ fn a07_leader_new_existing_exec_managed_new_session_and_attach_modes() {
603
+ let mut samples = Vec::new();
604
+ for existing in [false, true] {
605
+ let f = Fixture::new("argv-leader");
606
+ if existing {
607
+ save_runtime_state(
608
+ &f.ws,
609
+ &json!({"active_team_key":"argvteam","session_name":"team-argvteam","agents":{}}),
610
+ )
611
+ .unwrap();
612
+ }
613
+ for (tmux, external, expected_mode) in [
614
+ (true, true, LeaderStartMode::ExecProvider),
615
+ (false, false, LeaderStartMode::ManagedTmuxClient),
616
+ (false, true, LeaderStartMode::NewTmuxSession),
617
+ ] {
618
+ unsafe {
619
+ if tmux {
620
+ std::env::set_var("TMUX", "fixture,1,0");
621
+ } else {
622
+ std::env::remove_var("TMUX");
623
+ }
624
+ }
625
+ f.switch(Some("off"));
626
+ let base = f.leader(Provider::Pi, external, false).unwrap();
627
+ f.seed(true, json!({"pi":["--mode","rpc"]}));
628
+ f.switch(None);
629
+ let routed = f.leader(Provider::Pi, external, false).unwrap();
630
+ assert_eq!(routed.mode, expected_mode);
631
+ same_leader_env(&routed.leader_env, &base.leader_env);
632
+ assert_eq!(routed.workspace, base.workspace);
633
+ assert_eq!(routed.identity, base.identity);
634
+ if routed.mode == LeaderStartMode::ExecProvider {
635
+ assert_eq!(routed.argv, routed.provider_argv);
636
+ }
637
+ if routed.mode == LeaderStartMode::ManagedTmuxClient {
638
+ assert!(!routed.argv.iter().any(|v| v == "--mode" || v == "rpc"));
639
+ }
640
+ if routed.mode == LeaderStartMode::NewTmuxSession {
641
+ // Execute the actual encoded shell payload against a recorder,
642
+ // not tmux or a real provider. This path execs, so exit normally.
643
+ executable(
644
+ &f.env.root().join("pi-real"),
645
+ &format!(
646
+ "#!/bin/sh\nprintf '%s\\0' \"$@\" > '{}'\nexit 0\n",
647
+ f.native_file().display()
648
+ ),
649
+ );
650
+ let shell = routed.argv.last().unwrap();
651
+ assert!(shell.starts_with("cd "));
652
+ assert!(Command::new("/bin/sh")
653
+ .args(["-c", shell])
654
+ .stdout(Stdio::null())
655
+ .stderr(Stdio::null())
656
+ .status()
657
+ .unwrap()
658
+ .success());
659
+ let bytes = fs::read(f.native_file()).unwrap();
660
+ let actual = bytes
661
+ .split(|b| *b == 0)
662
+ .take(routed.provider_argv.len() - 1)
663
+ .map(|v| String::from_utf8(v.to_vec()).unwrap())
664
+ .collect::<Vec<_>>();
665
+ assert_eq!(actual, routed.provider_argv[1..]);
666
+ }
667
+ samples.push((base.provider_argv, routed.provider_argv));
668
+ }
669
+ unsafe {
670
+ std::env::remove_var("TMUX");
671
+ }
672
+ f.raw("invalid but opt-in");
673
+ f.switch(Some("on"));
674
+ let attach = f.leader(Provider::Pi, true, true).unwrap();
675
+ assert_eq!(attach.mode, LeaderStartMode::AttachExisting);
676
+ assert!(attach.provider_argv.is_empty());
677
+ }
678
+ assert_eq!(samples.len(), 6);
679
+ for (base, routed) in samples {
680
+ injected(&base, &["--mode".into(), "rpc".into()], &routed);
681
+ }
682
+ }
683
+
684
+ #[test]
685
+ #[serial(env)]
686
+ fn a07_pi_explicit_invalid_arguments_remain_rejected_under_routing() {
687
+ let f = Fixture::new("argv-pi-reject");
688
+ f.seed(true, json!({"pi":["--mode","rpc"]}));
689
+ f.switch(None);
690
+ for args in [
691
+ vec!["--thinking".to_string(), "invalid".into()],
692
+ vec!["--unknown".into()],
693
+ ] {
694
+ let result = crate::leader::start::leader_start_plan_after_ambient_authority_check(
695
+ Provider::Pi,
696
+ &args,
697
+ &f.ws,
698
+ false,
699
+ false,
700
+ None,
701
+ false,
702
+ );
703
+ assert!(
704
+ result.is_err(),
705
+ "route must not weaken Pi explicit input validation"
706
+ );
707
+ }
708
+ }
709
+
710
+ #[test]
711
+ #[serial(env)]
712
+ fn a08_quick_start_first_later_paused_dry_run_and_live_noop() {
713
+ let f = Fixture::new("argv-quick");
714
+ f.seed(true, json!({"codex":["cold-route"]}));
715
+ f.switch(None);
716
+ let t = f.start_team("codex", &["worker", "mate"]);
717
+ let observed_spawns = t.spawn_records();
718
+ let noop =
719
+ quick_start_with_transport_in_workspace(&f.ws, &f.team, None, true, Some("argvteam"), &t);
720
+ assert!(noop.is_ok());
721
+ assert_eq!(t.spawn_records().len(), 2, "live-noop must not spawn again");
722
+ let spec = f.spec("codex", &["worker", "mate"]);
723
+ f.raw("invalid schema");
724
+ f.switch(Some("on"));
725
+ let dry = offline();
726
+ assert!(launch_with_transport_in_workspace(&f.ws, &spec, true, false, true, &dry).is_ok());
727
+ assert!(dry.spawn_records().is_empty());
728
+ drop(f);
729
+ let f = Fixture::new("argv-paused");
730
+ let spec = f.spec("codex", &["worker", "mate"]);
731
+ f.raw("invalid schema");
732
+ f.switch(Some("on"));
733
+ // Typed paused flags, not assumptions about YAML renderer indentation.
734
+ use crate::model::yaml::Value as Yaml;
735
+ let mut paused = crate::model::yaml::loads(&fs::read_to_string(&spec).unwrap()).unwrap();
736
+ let Yaml::Map(top) = &mut paused else {
737
+ panic!("fixture spec object");
738
+ };
739
+ let Yaml::List(agents) = &mut top.iter_mut().find(|(key, _)| key == "agents").unwrap().1 else {
740
+ panic!("fixture agents list");
741
+ };
742
+ for agent in agents {
743
+ let Yaml::Map(fields) = agent else {
744
+ panic!("fixture agent object");
745
+ };
746
+ if let Some((_, value)) = fields.iter_mut().find(|(key, _)| key == "paused") {
747
+ *value = Yaml::Bool(true);
748
+ } else {
749
+ fields.push(("paused".into(), Yaml::Bool(true)));
750
+ }
751
+ }
752
+ fs::write(&spec, crate::model::yaml::dumps(&paused)).unwrap();
753
+ let paused_t = offline();
754
+ let result = launch_with_transport_in_workspace(&f.ws, &spec, false, false, true, &paused_t);
755
+ assert!(
756
+ result.is_ok(),
757
+ "paused-only must not validate opt-in routing: {result:?}"
758
+ );
759
+ assert!(paused_t.spawn_records().is_empty());
760
+ for (_, argv) in observed_spawns {
761
+ assert_route(&argv, &["cold-route"]);
762
+ }
763
+ }
764
+
765
+ #[test]
766
+ #[serial(env)]
767
+ fn a09_start_fresh_missing_backing_reset_and_add_use_new_route_once() {
768
+ let f = Fixture::new("argv-single");
769
+ f.start_team("codex", &["worker"]);
770
+ f.seed(true, json!({"codex":["single-route"]}));
771
+ f.switch(None);
772
+ let mut samples = Vec::new();
773
+ for backing in [false, true] {
774
+ f.mutate_agent("worker", |a| {
775
+ a["status"] = json!("stopped");
776
+ if backing {
777
+ a["session_id"] = json!("lost-session");
778
+ a["rollout_path"] = json!(f.ws.join("missing.jsonl"));
779
+ a["captured_at"] = json!("2026-01-01T00:00:00Z");
780
+ a["captured_via"] = json!("session_scan");
781
+ }
782
+ });
783
+ let t = offline();
784
+ let result = start_agent_with_transport(
785
+ &f.ws,
786
+ &AgentId::new("worker"),
787
+ false,
788
+ false,
789
+ true,
790
+ Some("argvteam"),
791
+ &t,
792
+ );
793
+ assert!(
794
+ result.is_ok(),
795
+ "allow-fresh missing backing fixture: {result:?}"
796
+ );
797
+ assert_eq!(t.spawn_records().len(), 1);
798
+ samples.push(t.spawn_records()[0].1.clone());
799
+ }
800
+ let reset = offline();
801
+ let result = reset_agent_with_transport(
802
+ &f.ws,
803
+ &AgentId::new("worker"),
804
+ true,
805
+ false,
806
+ Some("argvteam"),
807
+ &reset,
808
+ );
809
+ assert!(result.is_ok(), "reset fixture: {result:?}");
810
+ assert_eq!(reset.spawn_records().len(), 1);
811
+ samples.push(reset.spawn_records()[0].1.clone());
812
+ let role_file = f.ws.join("mate-role.md");
813
+ fs::write(&role_file, role("codex", "mate")).unwrap();
814
+ let added = offline().with_session_present(true);
815
+ add_agent_with_transport(
816
+ &f.team,
817
+ &AgentId::new("mate"),
818
+ &role_file,
819
+ false,
820
+ Some("argvteam"),
821
+ &added,
822
+ )
823
+ .unwrap();
824
+ assert_eq!(added.spawn_records().len(), 1);
825
+ samples.push(added.spawn_records()[0].1.clone());
826
+ assert_eq!(samples.len(), 4);
827
+ for argv in samples {
828
+ assert_route(&argv, &["single-route"]);
829
+ }
830
+ }
831
+
832
+ #[test]
833
+ #[serial(env)]
834
+ fn a09_resume_retains_session_metadata_while_routes_change() {
835
+ let f = Fixture::new("argv-resume");
836
+ f.start_team("pi", &["worker"]);
837
+ let selected = crate::state::projection::select_runtime_state(&f.ws, Some("argvteam")).unwrap();
838
+ let pending = selected["agents"]["worker"]["_pending_session_id"]
839
+ .as_str()
840
+ .unwrap()
841
+ .to_string();
842
+ let session = crate::lifecycle::launch::pi_mcp::pi_seat_paths(&f.ws, "argvteam", "worker")
843
+ .sessions
844
+ .join("valid.jsonl");
845
+ fs::create_dir_all(session.parent().unwrap()).unwrap();
846
+ fs::write(&session, format!("{}\n{}\n", json!({"type":"session","version":3,"id":pending,"cwd":f.ws,"timestamp":"2026-01-01T00:00:00Z"}), json!({"type":"message","id":"a","parentId":null,"timestamp":"2026-01-01T00:00:01Z","message":{"role":"user","content":[{"type":"text","text":"seed"}]}}))).unwrap();
847
+ f.mutate_agent("worker", |a| {
848
+ a["status"] = json!("stopped");
849
+ a["session_id"] = json!(pending);
850
+ a["rollout_path"] = json!(session);
851
+ a["captured_at"] = json!("2026-01-01T00:00:01Z");
852
+ a["captured_via"] = json!("session_scan");
853
+ a["capture_state"] = json!("captured");
854
+ a["first_send_at"] = json!("2026-01-01T00:00:01Z");
855
+ });
856
+ f.seed(true, json!({"pi":["resume-route"]}));
857
+ f.switch(None);
858
+ let t = offline();
859
+ start_agent_with_transport(
860
+ &f.ws,
861
+ &AgentId::new("worker"),
862
+ false,
863
+ false,
864
+ false,
865
+ Some("argvteam"),
866
+ &t,
867
+ )
868
+ .unwrap();
869
+ assert_eq!(t.spawn_records().len(), 1);
870
+ let argv = t.spawn_records()[0].1.clone();
871
+ assert!(argv
872
+ .windows(2)
873
+ .any(|p| p[0] == "--session" && Path::new(&p[1]) == session));
874
+ assert!(!argv.iter().any(|v| v == "--session-id"));
875
+ let retained = offline().with_session_present(true);
876
+ let reset = reset_agent_with_transport(
877
+ &f.ws,
878
+ &AgentId::new("worker"),
879
+ false,
880
+ false,
881
+ Some("argvteam"),
882
+ &retained,
883
+ );
884
+ // Existing reset policy requires discard-session even with a valid tuple.
885
+ // Protect refusal; routing must not invent a retain-session reset branch.
886
+ assert!(matches!(
887
+ reset,
888
+ Ok(crate::lifecycle::ResetAgentOutcome::Refused { .. })
889
+ ));
890
+ assert!(retained.spawn_records().is_empty());
891
+ let restarted = offline()
892
+ .with_session_present(true)
893
+ .with_default_liveness(crate::transport::PaneLiveness::Dead);
894
+ let report = restart_with_transport(&f.ws, false, Some("argvteam"), &restarted);
895
+ assert!(report.is_ok(), "in-session resume fixture: {report:?}");
896
+ assert_eq!(restarted.spawn_records().len(), 1);
897
+ let restart_argv = restarted.spawn_records()[0].1.clone();
898
+ assert!(restart_argv
899
+ .windows(2)
900
+ .any(|p| p[0] == "--session" && Path::new(&p[1]) == session));
901
+ assert_route(&argv, &["resume-route"]);
902
+ assert_route(&restart_argv, &["resume-route"]);
903
+ }
904
+
905
+ #[test]
906
+ #[serial(env)]
907
+ fn a09_remove_rollback_respawns_original_seat_with_route_once() {
908
+ let f = Fixture::new("argv-rollback");
909
+ f.start_team("codex", &["worker", "mate"]);
910
+ f.mutate_agent("worker", |a| {
911
+ a["pane_id"] = json!("%old");
912
+ a["window"] = json!("worker");
913
+ });
914
+ let _fail = f.env.with_env(
915
+ "TEAM_AGENT_TEST_FAIL_REMOVE_AFTER_AGENT_HEALTH_DELETE",
916
+ "argv-route-red",
917
+ );
918
+ f.seed(true, json!({"codex":["rollback-route"]}));
919
+ f.switch(None);
920
+ let t = offline()
921
+ .with_session_present(true)
922
+ .with_targets(vec![live_pane(&f.ws)]);
923
+ let result = crate::lifecycle::restart::remove_agent_with_transport(
924
+ &f.ws,
925
+ &AgentId::new("worker"),
926
+ true,
927
+ true,
928
+ Some("argvteam"),
929
+ &t,
930
+ );
931
+ assert!(result.is_err());
932
+ assert_eq!(
933
+ t.spawn_records().len(),
934
+ 1,
935
+ "fault must reach rollback respawn: {result:?}"
936
+ );
937
+ assert_route(&t.spawn_records()[0].1, &["rollback-route"]);
938
+ let state = crate::state::projection::select_runtime_state(&f.ws, Some("argvteam")).unwrap();
939
+ assert!(
940
+ state["agents"]["worker"].is_object(),
941
+ "rollback must restore original seat"
942
+ );
943
+ }
944
+
945
+ #[test]
946
+ #[serial(env)]
947
+ fn a09_pi_fork_inherits_session_and_routes_only_new_native_spawn() {
948
+ let f = Fixture::new("argv-fork");
949
+ f.start_team("pi", &["worker"]);
950
+ let session = crate::lifecycle::launch::pi_mcp::pi_seat_paths(&f.ws, "argvteam", "worker")
951
+ .sessions
952
+ .join("source.jsonl");
953
+ fs::create_dir_all(session.parent().unwrap()).unwrap();
954
+ let id = "fc317e54-bdbb-4ce5-8c1a-2b1bf33a9700";
955
+ fs::write(&session, format!("{}\n{}\n", json!({"type":"session","version":3,"id":id,"cwd":f.ws,"timestamp":"2026-01-01T00:00:00Z"}), json!({"type":"message","id":"seed01","parentId":null,"timestamp":"2026-01-01T00:00:01Z","message":{"role":"user","content":[{"type":"text","text":"fork seed"}]}}))).unwrap();
956
+ f.mutate_agent("worker", |a| {
957
+ a["session_id"] = json!(id);
958
+ a["rollout_path"] = json!(session);
959
+ a["captured_at"] = json!("2026-01-01T00:00:01Z");
960
+ a["captured_via"] = json!("session_scan");
961
+ a["capture_state"] = json!("captured");
962
+ });
963
+ let original = fs::read(&session).unwrap();
964
+ f.seed(true, json!({"pi":["fork-route"]}));
965
+ f.switch(None);
966
+ let t = offline().with_session_present(true);
967
+ let report = fork_agent_with_transport(
968
+ &f.ws,
969
+ &AgentId::new("worker"),
970
+ &AgentId::new("forked"),
971
+ None,
972
+ false,
973
+ Some("argvteam"),
974
+ &t,
975
+ );
976
+ assert!(
977
+ report.is_ok(),
978
+ "valid v3 Pi fork must reach native spawn: {report:?}"
979
+ );
980
+ assert_eq!(t.spawn_records().len(), 1);
981
+ assert_route(&t.spawn_records()[0].1, &["fork-route"]);
982
+ assert_eq!(fs::read(session).unwrap(), original);
983
+ }
984
+
985
+ #[test]
986
+ #[serial(env)]
987
+ fn a09_public_clone_reaches_native_argv_without_inheriting_old_route() {
988
+ let f = Fixture::new("argv-clone");
989
+ f.start_team("pi", &["worker"]);
990
+ // Public clone has no Transport seam: this fixture tmux executes the actual
991
+ // generated provider shell line against the argv recorder, never real tmux.
992
+ let calls = f.env.root().join("clone-spawn-calls");
993
+ let identity = f.env.root().join("clone-pane-identity");
994
+ executable(
995
+ &f.bin.join("tmux"),
996
+ &format!(
997
+ r#"#!/bin/sh
998
+ if [ "$1" = -S ] || [ "$1" = -L ]; then shift 2; fi
999
+ op=$1; shift
1000
+ case "$op" in
1001
+ -V) echo 'tmux 3.4';;
1002
+ has-session) exit 0;;
1003
+ new-window|new-session|split-window)
1004
+ previous=''
1005
+ for last do
1006
+ case "$previous" in -s|-t) session=$last;; -n) window=$last;; esac
1007
+ previous=$last
1008
+ done
1009
+ printf '%s\n' "$op" >> '{}'
1010
+ printf '%%99__TA_FIELD__%s__TA_FIELD__1__TA_FIELD__%s__TA_FIELD__0__TA_FIELD__/dev/pts/99__TA_FIELD__pi__TA_FIELD__1__TA_FIELD__{}__TA_FIELD__0__TA_FIELD__0__TA_FIELD__1__TA_FIELD__\n' "$session" "$window" > '{}'
1011
+ /bin/sh -c "$last" >/dev/null 2>&1
1012
+ echo %99; exit 0;;
1013
+ list-panes) while IFS= read -r line; do printf '%s\n' "$line"; done < '{}';;
1014
+ display-message) for last do :; done; case "$last" in *pane_id*) echo %99;; *pane_dead*) echo 0;; *pane_width*) echo 120;; esac;;
1015
+ capture-pane) echo '> '; ;;
1016
+ *) exit 0;;
1017
+ esac
1018
+ "#,
1019
+ calls.display(),
1020
+ f.ws.display(),
1021
+ identity.display(),
1022
+ identity.display()
1023
+ ),
1024
+ );
1025
+ f.seed(true, json!({"pi":["clone-route"]}));
1026
+ f.switch(None);
1027
+ let cloned = crate::lifecycle::clone_agent(
1028
+ &f.ws,
1029
+ &AgentId::new("worker"),
1030
+ &AgentId::new("cloned"),
1031
+ None,
1032
+ false,
1033
+ Some("argvteam"),
1034
+ );
1035
+ assert!(
1036
+ cloned.is_ok(),
1037
+ "public clone fixture must complete: {cloned:?}"
1038
+ );
1039
+ assert_eq!(fs::read_to_string(calls).unwrap().lines().count(), 1);
1040
+ let bytes = fs::read(f.native_file()).unwrap();
1041
+ let args = bytes
1042
+ .split(|b| *b == 0)
1043
+ .filter(|b| !b.is_empty())
1044
+ .map(|b| String::from_utf8(b.to_vec()).unwrap())
1045
+ .collect::<Vec<_>>();
1046
+ assert_eq!(args.first().map(String::as_str), Some("clone-route"));
1047
+ assert_eq!(
1048
+ args.iter().filter(|v| v.as_str() == "clone-route").count(),
1049
+ 1
1050
+ );
1051
+ }
1052
+
1053
+ #[test]
1054
+ #[serial(env)]
1055
+ fn a09_allowed_force_recreate_uses_route_once_after_old_seat_is_dead() {
1056
+ let f = Fixture::new("argv-force");
1057
+ f.start_team("codex", &["worker"]);
1058
+ let role_file = f.ws.join("replacement.md");
1059
+ fs::write(&role_file, role("codex", "worker")).unwrap();
1060
+ f.seed(true, json!({"codex":["force-route"]}));
1061
+ f.switch(None);
1062
+ let t = offline();
1063
+ let result = crate::lifecycle::launch::add_agent_with_transport_force(
1064
+ &f.team,
1065
+ &AgentId::new("worker"),
1066
+ &role_file,
1067
+ false,
1068
+ Some("argvteam"),
1069
+ true,
1070
+ &t,
1071
+ );
1072
+ assert!(
1073
+ result.is_ok(),
1074
+ "allowed dead-seat force fixture must complete: {result:?}"
1075
+ );
1076
+ assert_eq!(t.spawn_records().len(), 1);
1077
+ assert_route(&t.spawn_records()[0].1, &["force-route"]);
1078
+ }
1079
+
1080
+ #[test]
1081
+ #[serial(env)]
1082
+ fn a10_live_start_noop_is_not_validated_against_bad_route_config() {
1083
+ let f = Fixture::new("argv-keep");
1084
+ f.start_team("codex", &["worker"]);
1085
+ f.mutate_agent("worker", |a| {
1086
+ a["pane_id"] = json!("%old");
1087
+ a["window"] = json!("worker");
1088
+ });
1089
+ f.raw("invalid JSON");
1090
+ f.switch(Some("on"));
1091
+ let t = offline()
1092
+ .with_session_present(true)
1093
+ .with_targets(vec![live_pane(&f.ws)])
1094
+ .with_windows(vec![WindowName::new("worker")])
1095
+ .with_liveness("%old", crate::transport::PaneLiveness::Live);
1096
+ // The baseline restart library deliberately rebuilds live seats. Observe
1097
+ // the actual existing live-seat Noop surface, without inventing new policy.
1098
+ let result = start_agent_with_transport(
1099
+ &f.ws,
1100
+ &AgentId::new("worker"),
1101
+ false,
1102
+ false,
1103
+ false,
1104
+ Some("argvteam"),
1105
+ &t,
1106
+ );
1107
+ assert!(
1108
+ matches!(result, Ok(crate::lifecycle::StartAgentOutcome::Noop { .. })),
1109
+ "live Noop must not consult router: {result:?}"
1110
+ );
1111
+ assert!(t.spawn_records().is_empty());
1112
+ }
1113
+
1114
+ #[test]
1115
+ #[serial(env)]
1116
+ fn a10_restart_first_parallel_later_reloads_mapping_without_stacking() {
1117
+ let f = Fixture::new("argv-restart");
1118
+ f.start_team("codex", &["worker", "mate", "third"]);
1119
+ let mut samples = Vec::new();
1120
+ for marker in ["route-generation-one", "route-generation-two"] {
1121
+ f.seed(true, json!({"codex":[marker]}));
1122
+ f.switch(None);
1123
+ let t = offline();
1124
+ let result = restart_with_transport(&f.ws, true, Some("argvteam"), &t);
1125
+ assert!(
1126
+ result.is_ok(),
1127
+ "restart fixture must reach plans: {result:?}"
1128
+ );
1129
+ assert_eq!(t.spawn_records().len(), 3);
1130
+ for (_, argv) in t.spawn_records() {
1131
+ samples.push((marker, argv));
1132
+ }
1133
+ }
1134
+ assert_eq!(samples.len(), 6);
1135
+ for (marker, argv) in samples {
1136
+ assert_route(&argv, &[marker]);
1137
+ if marker.ends_with("two") {
1138
+ assert!(!argv.iter().any(|v| v == "route-generation-one"));
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ #[test]
1144
+ #[serial(env)]
1145
+ fn a11_enabled_bad_schema_fails_before_native_spawn_at_all_three_boundaries() {
1146
+ let f = Fixture::new("argv-errors");
1147
+ f.start_team("codex", &["worker"]);
1148
+ for raw in [
1149
+ "bad JSON",
1150
+ r#"{"schema_version":2,"enabled":true,"providers":{}}"#,
1151
+ r#"{"schema_version":1,"enabled":true,"providers":{"unknown":[]}}"#,
1152
+ r#"{"schema_version":1,"enabled":true,"providers":{"pi":[7]}}"#,
1153
+ r#"{"schema_version":1,"enabled":true,"providers":{"pi":["\u0000"]}}"#,
1154
+ r#"{"schema_version":1,"enabled":true,"providers":{"pi":[],"pi":[]}}"#,
1155
+ ] {
1156
+ f.raw(raw);
1157
+ f.switch(Some("on"));
1158
+ let spec = f.spec("codex", &["worker"]);
1159
+ let cold = offline();
1160
+ assert!(
1161
+ launch_with_transport_in_workspace(&f.ws, &spec, false, false, true, &cold).is_err(),
1162
+ "ON cold must reject {raw}"
1163
+ );
1164
+ assert!(cold.spawn_records().is_empty());
1165
+ let single = offline();
1166
+ assert!(
1167
+ start_agent_with_transport(
1168
+ &f.ws,
1169
+ &AgentId::new("worker"),
1170
+ true,
1171
+ false,
1172
+ false,
1173
+ Some("argvteam"),
1174
+ &single
1175
+ )
1176
+ .is_err(),
1177
+ "ON single must reject {raw}"
1178
+ );
1179
+ assert!(single.spawn_records().is_empty());
1180
+ let leader = f.leader(Provider::Codex, false, false);
1181
+ assert!(leader.is_err(), "ON leader must reject {raw}");
1182
+ }
1183
+ f.seed(true, json!({"pi":[]}));
1184
+ f.switch(None);
1185
+ fs::remove_file(f.config()).unwrap();
1186
+ fs::create_dir(f.config()).unwrap();
1187
+ f.switch(Some("on"));
1188
+ assert!(f.leader(Provider::Codex, false, false).is_err());
1189
+ fs::remove_dir(f.config()).unwrap();
1190
+ let _home = f.env.with_env("HOME", "relative-home");
1191
+ assert!(f.leader(Provider::Codex, false, false).is_err());
1192
+ }
1193
+
1194
+ #[test]
1195
+ #[serial(env)]
1196
+ fn a13_materializer_version_catalog_and_nonagent_command_builders_are_not_routed() {
1197
+ let f = Fixture::new("argv-probes");
1198
+ f.seed(
1199
+ true,
1200
+ json!({"pi":["--mode","rpc"],"copilot":["must-not-pollute-probe"]}),
1201
+ );
1202
+ f.switch(None);
1203
+ let base = f.cold("pi");
1204
+ let _ = crate::lifecycle::launch::pi_mcp::pi_model_candidates("team-agent/qwen3.8-27b");
1205
+ let probes = fs::read_to_string(&f.probes).unwrap();
1206
+ assert!(probes.contains("--version"));
1207
+ assert!(probes.contains("--list-models"));
1208
+ for line in probes.lines() {
1209
+ assert!(!line.contains("rpc") && !line.contains("--mode"));
1210
+ }
1211
+ assert!(
1212
+ !f.native_file().exists(),
1213
+ "plan/materializer must not execute native provider session"
1214
+ );
1215
+ assert_route(&base, &["--mode", "rpc"]);
1216
+ }
1217
+
1218
+ #[test]
1219
+ #[serial(env)]
1220
+ fn a07_attach_existing_never_opens_enabled_invalid_route_config() {
1221
+ let f = Fixture::new("argv-attach");
1222
+ f.raw("invalid JSON");
1223
+ f.switch(Some("on"));
1224
+ let plan = f.leader(Provider::Pi, true, true).unwrap();
1225
+ assert_eq!(plan.mode, LeaderStartMode::AttachExisting);
1226
+ assert!(plan.provider_argv.is_empty());
1227
+ assert!(!plan.argv.iter().any(|v| v == "rpc"));
1228
+ }
1229
+
1230
+ #[test]
1231
+ #[serial(env)]
1232
+ fn a13_all_provider_version_auth_and_cursor_mcp_auxiliary_argv_are_native() {
1233
+ let f = Fixture::new("argv-aux");
1234
+ let mappings = CANONICAL
1235
+ .iter()
1236
+ .map(|(k, _)| ((*k).to_string(), json!(["route-must-not-enter-aux"])))
1237
+ .collect::<serde_json::Map<_, _>>();
1238
+ f.seed(true, Value::Object(mappings));
1239
+ f.switch(None);
1240
+ for (_, provider) in CANONICAL {
1241
+ assert!(crate::provider::get_adapter(*provider).version().is_ok());
1242
+ }
1243
+ let _ = crate::provider::get_adapter(Provider::ClaudeCode)
1244
+ .auth_hint(crate::model::enums::AuthMode::Subscription);
1245
+ assert_eq!(
1246
+ crate::lifecycle::launch::cursor_mcp_enable_argv(),
1247
+ ["agent", "mcp", "enable", "team_orchestrator"]
1248
+ );
1249
+ let _ = f.cold("copilot");
1250
+ let probes = fs::read_to_string(&f.probes).unwrap();
1251
+ assert!(!probes.contains("route-must-not-enter-aux"));
1252
+ assert!(probes.contains("--version"));
1253
+ }
1254
+
1255
+ #[test]
1256
+ #[serial(env)]
1257
+ fn a14_posix_native_recorder_literal_vector_positive_guard() {
1258
+ let f = Fixture::new("argv-posix-positive");
1259
+ let sentinel = f.env.root().join("MUST-NOT-EXIST");
1260
+ let argv = vec![
1261
+ f.bin.join("pi").to_string_lossy().into_owned(),
1262
+ "".into(),
1263
+ " x y ".into(),
1264
+ "路由🚀".into(),
1265
+ "'\"\\\n".into(),
1266
+ format!(
1267
+ ";touch {};$(touch {});`touch {}`",
1268
+ sentinel.display(),
1269
+ sentinel.display(),
1270
+ sentinel.display()
1271
+ ),
1272
+ "--help".into(),
1273
+ "--json".into(),
1274
+ "--no-display".into(),
1275
+ "--".into(),
1276
+ ];
1277
+ for leader in [false, true] {
1278
+ let line = if leader {
1279
+ crate::tmux_backend::leader_shell_wrapper_command(
1280
+ &argv,
1281
+ &f.ws,
1282
+ &BTreeMap::new(),
1283
+ &[],
1284
+ "pi",
1285
+ )
1286
+ } else {
1287
+ crate::tmux_backend::worker_shell_wrapper_command(
1288
+ &argv,
1289
+ &f.ws,
1290
+ &BTreeMap::new(),
1291
+ &[],
1292
+ "pi",
1293
+ )
1294
+ };
1295
+ let status = Command::new("/bin/sh")
1296
+ .args(["-c", &line])
1297
+ .stdout(Stdio::null())
1298
+ .stderr(Stdio::null())
1299
+ .status()
1300
+ .unwrap();
1301
+ assert!(!status.success());
1302
+ let bytes = fs::read(f.native_file()).unwrap();
1303
+ let received = bytes
1304
+ .split(|b| *b == 0)
1305
+ .take(argv.len() - 1)
1306
+ .map(|v| String::from_utf8(v.to_vec()).unwrap())
1307
+ .collect::<Vec<_>>();
1308
+ assert_eq!(received, argv[1..]);
1309
+ assert!(!sentinel.exists());
1310
+ }
1311
+ }
1312
+
1313
+ #[test]
1314
+ #[serial(env)]
1315
+ fn a14_conpty_spawn_request_frame_preserves_routed_vector_not_windows_live_pass() {
1316
+ use conpty_transport::protocol::{read_frame, write_frame, SpawnRequest};
1317
+ let f = Fixture::new("argv-conpty");
1318
+ f.seed(
1319
+ true,
1320
+ json!({"codex":["", "a b", "路由", "\"x\"\\", "line\nbreak"]}),
1321
+ );
1322
+ f.switch(None);
1323
+ let argv = f.cold("codex");
1324
+ assert_eq!(&argv[1..6], &["", "a b", "路由", "\"x\"\\", "line\nbreak"]);
1325
+ let request = SpawnRequest {
1326
+ session: "team-argvteam".into(),
1327
+ window: "worker".into(),
1328
+ argv: argv.clone(),
1329
+ cwd: f.ws.to_string_lossy().into_owned(),
1330
+ env: BTreeMap::new(),
1331
+ env_unset: vec![],
1332
+ cols: 120,
1333
+ rows: 30,
1334
+ };
1335
+ let mut wire = Vec::new();
1336
+ write_frame(&mut wire, &serde_json::to_vec(&request).unwrap()).unwrap();
1337
+ let decoded: SpawnRequest =
1338
+ serde_json::from_slice(&read_frame(&mut wire.as_slice()).unwrap()).unwrap();
1339
+ assert_eq!(decoded.argv, argv);
1340
+ assert_eq!(decoded.cwd, request.cwd);
1341
+ }
1342
+
1343
+ #[test]
1344
+ #[serial(env)]
1345
+ fn a15_provider_drift_and_unknown_force_remain_refused() {
1346
+ let f = Fixture::new("argv-drift");
1347
+ f.start_team("codex", &["worker"]);
1348
+ f.seed(
1349
+ true,
1350
+ json!({"pi":["must-not-spawn"],"codex":["must-not-spawn"]}),
1351
+ );
1352
+ f.switch(None);
1353
+ f.mutate_agent("worker", |a| {
1354
+ a["status"] = json!("stopped");
1355
+ a["pane_id"] = json!("%old");
1356
+ });
1357
+ let before = crate::state::projection::select_runtime_state(&f.ws, Some("argvteam")).unwrap();
1358
+ let drift = crate::lifecycle::role_config::start_agent_from_role(
1359
+ &f.ws,
1360
+ &AgentId::new("worker"),
1361
+ &crate::lifecycle::role_config::RoleConfigPatch {
1362
+ provider: Some("pi".into()),
1363
+ ..Default::default()
1364
+ },
1365
+ true,
1366
+ Some("argvteam"),
1367
+ );
1368
+ assert!(
1369
+ matches!(drift, Err(crate::lifecycle::LifecycleError::RequirementUnmet(ref message)) if message.contains("provider change is not allowed"))
1370
+ );
1371
+ assert!(!f.native_file().exists());
1372
+ let role_file = f.ws.join("replacement.md");
1373
+ fs::write(&role_file, role("codex", "worker")).unwrap();
1374
+ // The public role-config admission guard owns unknown/live force refusal.
1375
+ // Its native tmux fixture returns inconclusive identity, never proven dead.
1376
+ executable(&f.bin.join("tmux"), "#!/bin/sh\ncase \"$*\" in\n*-V*) echo 'tmux 3.4';;\n*display-message*) echo 'fixture backend unavailable' >&2; exit 1;;\n*) exit 0;;\nesac\n");
1377
+ let forced = crate::lifecycle::role_config::add_agent_from_role(
1378
+ &f.ws,
1379
+ &AgentId::new("worker"),
1380
+ Some(&role_file),
1381
+ &Default::default(),
1382
+ true,
1383
+ Some("argvteam"),
1384
+ );
1385
+ assert!(
1386
+ forced.is_err(),
1387
+ "unknown old pane must not be force-recreated"
1388
+ );
1389
+ assert!(!f.native_file().exists());
1390
+ let after = crate::state::projection::select_runtime_state(&f.ws, Some("argvteam")).unwrap();
1391
+ assert_eq!(
1392
+ after["agents"]["worker"]["provider"],
1393
+ before["agents"]["worker"]["provider"]
1394
+ );
1395
+ }
1396
+
1397
+ #[test]
1398
+ #[serial(env)]
1399
+ fn a15_route_does_not_relax_running_start_reset_discard_or_unsupported_fork() {
1400
+ let f = Fixture::new("argv-old-refusals");
1401
+ f.start_team("codex", &["worker"]);
1402
+ f.mutate_agent("worker", |a| {
1403
+ a["pane_id"] = json!("%old");
1404
+ a["window"] = json!("worker");
1405
+ });
1406
+ f.seed(true, json!({"codex":["refusal-route"]}));
1407
+ f.switch(None);
1408
+ let t = offline()
1409
+ .with_session_present(true)
1410
+ .with_targets(vec![live_pane(&f.ws)])
1411
+ .with_windows(vec![WindowName::new("worker")])
1412
+ .with_liveness("%old", crate::transport::PaneLiveness::Live);
1413
+ let start = start_agent_with_transport(
1414
+ &f.ws,
1415
+ &AgentId::new("worker"),
1416
+ false,
1417
+ false,
1418
+ false,
1419
+ Some("argvteam"),
1420
+ &t,
1421
+ );
1422
+ assert!(
1423
+ matches!(
1424
+ start,
1425
+ Err(_) | Ok(crate::lifecycle::StartAgentOutcome::Noop { .. })
1426
+ ),
1427
+ "existing live seat must refuse/noop without spawn"
1428
+ );
1429
+ assert!(t.spawn_records().is_empty());
1430
+ let reset = reset_agent_with_transport(
1431
+ &f.ws,
1432
+ &AgentId::new("worker"),
1433
+ false,
1434
+ false,
1435
+ Some("argvteam"),
1436
+ &t,
1437
+ )
1438
+ .unwrap();
1439
+ assert!(matches!(
1440
+ reset,
1441
+ crate::lifecycle::ResetAgentOutcome::Refused { .. }
1442
+ ));
1443
+ assert!(t.spawn_records().is_empty());
1444
+ let fork = fork_agent_with_transport(
1445
+ &f.ws,
1446
+ &AgentId::new("worker"),
1447
+ &AgentId::new("forked"),
1448
+ None,
1449
+ false,
1450
+ Some("argvteam"),
1451
+ &t,
1452
+ );
1453
+ assert!(fork.is_err());
1454
+ assert!(t.spawn_records().is_empty());
1455
+ let before = load_runtime_state(&f.ws).unwrap();
1456
+ assert!(before.pointer("/agents/forked").is_none());
1457
+ }