@team-agent/installer 0.5.61 → 0.5.63

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 (250) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -2
  4. package/crates/team-agent/src/cli/attach_app_server_leader.rs +1 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +1 -0
  6. package/crates/team-agent/src/cli/emit.rs +41 -7
  7. package/crates/team-agent/src/cli/helpers.rs +1 -0
  8. package/crates/team-agent/src/cli/leader.rs +6 -1
  9. package/crates/team-agent/src/cli/leaders.rs +2 -0
  10. package/crates/team-agent/src/cli/mod.rs +33 -3
  11. package/crates/team-agent/src/cli/named_address.rs +8 -1
  12. package/crates/team-agent/src/cli/profile.rs +2 -1
  13. package/crates/team-agent/src/cli/send/coordinator.rs +1 -0
  14. package/crates/team-agent/src/cli/send/mailbox.rs +3 -0
  15. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  16. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  17. package/crates/team-agent/src/cli/send/resolve.rs +206 -1
  18. package/crates/team-agent/src/cli/send.rs +2 -1
  19. package/crates/team-agent/src/cli/spec.rs +1 -0
  20. package/crates/team-agent/src/cli/status.rs +2 -1
  21. package/crates/team-agent/src/cli/status_port/compact.rs +1 -0
  22. package/crates/team-agent/src/cli/status_port/format.rs +1 -0
  23. package/crates/team-agent/src/cli/status_port/inbox.rs +1 -0
  24. package/crates/team-agent/src/cli/tests/verb_profile.rs +38 -0
  25. package/crates/team-agent/src/cli/types.rs +3 -2
  26. package/crates/team-agent/src/communication_mode/mod.rs +1 -0
  27. package/crates/team-agent/src/compiler.rs +1 -0
  28. package/crates/team-agent/src/conpty/backend.rs +1 -0
  29. package/crates/team-agent/src/conpty/mod.rs +1 -0
  30. package/crates/team-agent/src/coordinator/backoff.rs +5 -8
  31. package/crates/team-agent/src/coordinator/conpty_shim.rs +3 -2
  32. package/crates/team-agent/src/coordinator/health.rs +1 -0
  33. package/crates/team-agent/src/coordinator/mod.rs +2 -2
  34. package/crates/team-agent/src/coordinator/orphan.rs +4 -116
  35. package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
  36. package/crates/team-agent/src/coordinator/runtime_observation.rs +1 -0
  37. package/crates/team-agent/src/coordinator/steps/abnormal.rs +15 -866
  38. package/crates/team-agent/src/coordinator/steps/delivery.rs +1 -0
  39. package/crates/team-agent/src/coordinator/steps/health_sync.rs +1 -0
  40. package/crates/team-agent/src/coordinator/steps/mod.rs +2 -0
  41. package/crates/team-agent/src/coordinator/steps/persist.rs +1 -0
  42. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +1 -0
  43. package/crates/team-agent/src/coordinator/steps/session_gate.rs +1 -0
  44. package/crates/team-agent/src/coordinator/tests/abnormal.rs +0 -174
  45. package/crates/team-agent/src/coordinator/tests/api_error_recovery.rs +139 -375
  46. package/crates/team-agent/src/coordinator/tests/basics.rs +0 -184
  47. package/crates/team-agent/src/coordinator/tests/mod.rs +2 -17
  48. package/crates/team-agent/src/coordinator/tests/tick_core.rs +0 -49
  49. package/crates/team-agent/src/coordinator/tick.rs +23 -40
  50. package/crates/team-agent/src/coordinator/types.rs +3 -176
  51. package/crates/team-agent/src/db/agent_health_capture.rs +1 -0
  52. package/crates/team-agent/src/db/message_store.rs +2 -1
  53. package/crates/team-agent/src/db/migration.rs +5 -4
  54. package/crates/team-agent/src/db/mod.rs +1 -0
  55. package/crates/team-agent/src/db/schema.rs +1 -0
  56. package/crates/team-agent/src/diagnose/comms.rs +2 -1
  57. package/crates/team-agent/src/diagnose/mod.rs +1 -0
  58. package/crates/team-agent/src/diagnose/orphans.rs +1 -0
  59. package/crates/team-agent/src/layout/manager.rs +3 -7
  60. package/crates/team-agent/src/layout/mod.rs +2 -2
  61. package/crates/team-agent/src/layout/overlay.rs +6 -1
  62. package/crates/team-agent/src/layout/placement.rs +1 -0
  63. package/crates/team-agent/src/layout/recovery.rs +3 -0
  64. package/crates/team-agent/src/layout/runtime_sessions.rs +8 -3
  65. package/crates/team-agent/src/layout/sessions.rs +8 -1
  66. package/crates/team-agent/src/layout/tmux_endpoint.rs +3 -0
  67. package/crates/team-agent/src/layout/worker_env.rs +3 -0
  68. package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
  69. package/crates/team-agent/src/leader/helpers.rs +1 -0
  70. package/crates/team-agent/src/leader/incident.rs +1 -0
  71. package/crates/team-agent/src/leader/lease.rs +27 -22
  72. package/crates/team-agent/src/leader/mod.rs +2 -3
  73. package/crates/team-agent/src/leader/owner_bind.rs +13 -3
  74. package/crates/team-agent/src/leader/provider_attribution.rs +1 -0
  75. package/crates/team-agent/src/leader/rediscover.rs +4 -17
  76. package/crates/team-agent/src/leader/registry.rs +3 -15
  77. package/crates/team-agent/src/leader/start.rs +373 -125
  78. package/crates/team-agent/src/leader/takeover.rs +10 -232
  79. package/crates/team-agent/src/leader/tests/claim_leader_mailbox_flush_contract.rs +544 -0
  80. package/crates/team-agent/src/leader/tests/historical_inventory_upgrade_gate_red.rs +624 -0
  81. package/crates/team-agent/src/leader/tests/identity.rs +7 -2
  82. package/crates/team-agent/src/leader/tests/idle.rs +0 -193
  83. package/crates/team-agent/src/leader/tests/lease_api.rs +0 -45
  84. package/crates/team-agent/src/leader/tests/mod.rs +4 -41
  85. package/crates/team-agent/src/leader/tests/rehomed_env.rs +83 -0
  86. package/crates/team-agent/src/leader/tests/team_in_team_state_scope_red.rs +1273 -0
  87. package/crates/team-agent/src/leader/tests/terminal_and_replay_vs_rerender_invariants_red.rs +417 -0
  88. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +22 -103
  89. package/crates/team-agent/src/leader/types.rs +1 -28
  90. package/crates/team-agent/src/lib.rs +3 -0
  91. package/crates/team-agent/src/lifecycle/display.rs +7 -1
  92. package/crates/team-agent/src/lifecycle/helpers.rs +3 -1
  93. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +2 -2
  94. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +1 -0
  95. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +1 -0
  96. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +1 -0
  97. package/crates/team-agent/src/lifecycle/launch/plan.rs +2 -2
  98. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +4 -26
  99. package/crates/team-agent/src/lifecycle/launch/readiness.rs +0 -27
  100. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -27
  101. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +4 -32
  102. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +1 -0
  103. package/crates/team-agent/src/lifecycle/launch.rs +11 -12
  104. package/crates/team-agent/src/lifecycle/mod.rs +2 -1
  105. package/crates/team-agent/src/lifecycle/restart/agent.rs +3 -66
  106. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +2 -2
  107. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -5
  108. package/crates/team-agent/src/lifecycle/restart/remove.rs +1 -1
  109. package/crates/team-agent/src/lifecycle/restart/selection.rs +3 -3
  110. package/crates/team-agent/src/lifecycle/restart.rs +13 -15
  111. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1007 -0
  112. package/crates/team-agent/src/lifecycle/tests/b0_legacy_snapshot_nonauthority_contract.rs +265 -0
  113. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +459 -0
  114. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +849 -0
  115. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +323 -0
  116. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +423 -0
  117. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +737 -0
  118. package/crates/team-agent/src/lifecycle/tests/codex_mcp_approval_inheritance_red.rs +1187 -0
  119. package/crates/team-agent/src/lifecycle/tests/codex_weak_window_attribution_red.rs +683 -0
  120. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +394 -0
  121. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +1856 -0
  122. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +917 -0
  123. package/crates/team-agent/src/lifecycle/tests/display_adaptive_red.rs +481 -0
  124. package/crates/team-agent/src/lifecycle/tests/f032_startup_prompt_best_effort_red.rs +301 -0
  125. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +516 -0
  126. package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +986 -0
  127. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +651 -0
  128. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +2 -0
  129. package/crates/team-agent/src/lifecycle/tests/quick_start_worker_readiness_red.rs +332 -0
  130. package/crates/team-agent/src/lifecycle/tests/realmachine_clusters_1_6_red.rs +846 -0
  131. package/crates/team-agent/src/lifecycle/tests/realmachine_residual_g1_g4_red.rs +648 -0
  132. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +910 -0
  133. package/crates/team-agent/src/lifecycle/tests/restart_liveness_red.rs +630 -0
  134. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1083 -0
  135. package/crates/team-agent/src/lifecycle/tests/restart_session_capture_red.rs +1677 -0
  136. package/crates/team-agent/src/lifecycle/tests/resume_recover_red.rs +410 -0
  137. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +428 -0
  138. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1032 -0
  139. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +869 -0
  140. package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +1249 -0
  141. package/crates/team-agent/src/lifecycle/tests/swallow_batch4_semantics_red.rs +342 -0
  142. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +542 -0
  143. package/crates/team-agent/src/lifecycle/tests/team_in_team_sibling_quick_start_red.rs +1103 -0
  144. package/crates/team-agent/src/lifecycle/tests/team_in_team_state_scope_red.rs +1276 -0
  145. package/crates/team-agent/src/lifecycle/tests/team_key_retirement_roster_red.rs +145 -0
  146. package/crates/team-agent/src/lifecycle/tests/team_key_retirement_txn_red.rs +603 -0
  147. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +837 -0
  148. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +928 -0
  149. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +951 -0
  150. package/crates/team-agent/src/lifecycle/tests/worker_dangerous_bypass_argv_red.rs +327 -0
  151. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +760 -0
  152. package/crates/team-agent/src/lifecycle/tests.rs +59 -0
  153. package/crates/team-agent/src/lifecycle/types.rs +0 -9
  154. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  155. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -0
  156. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +1 -0
  157. package/crates/team-agent/src/mcp_server/mod.rs +1 -0
  158. package/crates/team-agent/src/mcp_server/normalize.rs +4 -3
  159. package/crates/team-agent/src/mcp_server/tools.rs +1 -0
  160. package/crates/team-agent/src/mcp_server/types.rs +1 -18
  161. package/crates/team-agent/src/mcp_server/wire.rs +5 -5
  162. package/crates/team-agent/src/messaging/activity.rs +1 -0
  163. package/crates/team-agent/src/messaging/address.rs +1 -0
  164. package/crates/team-agent/src/messaging/delivery.rs +4 -3
  165. package/crates/team-agent/src/messaging/helpers.rs +2 -1
  166. package/crates/team-agent/src/messaging/leader_channel.rs +1 -0
  167. package/crates/team-agent/src/messaging/leader_receiver.rs +2 -1
  168. package/crates/team-agent/src/messaging/mod.rs +10 -14
  169. package/crates/team-agent/src/messaging/peers.rs +2 -0
  170. package/crates/team-agent/src/messaging/persist.rs +3 -1
  171. package/crates/team-agent/src/messaging/presentation.rs +2 -1
  172. package/crates/team-agent/src/messaging/results.rs +1 -0
  173. package/crates/team-agent/src/messaging/scheduler.rs +1 -0
  174. package/crates/team-agent/src/messaging/selftest.rs +1 -1
  175. package/crates/team-agent/src/messaging/send.rs +41 -8
  176. package/crates/team-agent/src/messaging/tests/mod.rs +5 -0
  177. package/crates/team-agent/src/messaging/types.rs +2 -2
  178. package/crates/team-agent/src/messaging/watchers.rs +19 -13
  179. package/crates/team-agent/src/model/enums.rs +1 -0
  180. package/crates/team-agent/src/model/errors.rs +1 -0
  181. package/crates/team-agent/src/model/ids.rs +1 -0
  182. package/crates/team-agent/src/model/mod.rs +1 -0
  183. package/crates/team-agent/src/model/name_similarity.rs +1 -0
  184. package/crates/team-agent/src/model/pane_authority_refusal.rs +1 -0
  185. package/crates/team-agent/src/model/paths.rs +1 -0
  186. package/crates/team-agent/src/model/permissions.rs +3 -2
  187. package/crates/team-agent/src/model/routing.rs +1 -0
  188. package/crates/team-agent/src/model/spec.rs +1 -0
  189. package/crates/team-agent/src/model/task_graph.rs +1 -0
  190. package/crates/team-agent/src/model/yaml/tests.rs +1 -0
  191. package/crates/team-agent/src/model/yaml.rs +1 -0
  192. package/crates/team-agent/src/packaging/install.rs +9 -147
  193. package/crates/team-agent/src/packaging/migrate.rs +2 -0
  194. package/crates/team-agent/src/packaging/mod.rs +1 -0
  195. package/crates/team-agent/src/packaging/repair.rs +2 -0
  196. package/crates/team-agent/src/packaging/tests.rs +29 -218
  197. package/crates/team-agent/src/packaging/types.rs +8 -15
  198. package/crates/team-agent/src/platform/argv.rs +4 -0
  199. package/crates/team-agent/src/platform/errors.rs +10 -1
  200. package/crates/team-agent/src/platform/file_lock.rs +4 -157
  201. package/crates/team-agent/src/platform/mod.rs +0 -61
  202. package/crates/team-agent/src/platform/process.rs +1 -0
  203. package/crates/team-agent/src/provider/adapter.rs +1 -0
  204. package/crates/team-agent/src/provider/adapters/claude.rs +1 -0
  205. package/crates/team-agent/src/provider/adapters/claude_fork.rs +1 -0
  206. package/crates/team-agent/src/provider/adapters/codex.rs +1 -0
  207. package/crates/team-agent/src/provider/adapters/copilot.rs +1 -0
  208. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +1 -0
  209. package/crates/team-agent/src/provider/adapters/fake.rs +1 -0
  210. package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
  211. package/crates/team-agent/src/provider/approvals/mod.rs +1 -0
  212. package/crates/team-agent/src/provider/approvals/parsing.rs +2 -5
  213. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +6 -5
  214. package/crates/team-agent/src/provider/classify.rs +1 -0
  215. package/crates/team-agent/src/provider/faults.rs +1 -26
  216. package/crates/team-agent/src/provider/helpers.rs +1 -0
  217. package/crates/team-agent/src/provider/mod.rs +1 -1
  218. package/crates/team-agent/src/provider/session/capture.rs +1 -4
  219. package/crates/team-agent/src/provider/session/context_fork/claude.rs +1 -0
  220. package/crates/team-agent/src/provider/session/context_fork/codex.rs +1 -0
  221. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +1 -0
  222. package/crates/team-agent/src/provider/session/context_fork.rs +1 -0
  223. package/crates/team-agent/src/provider/session/mod.rs +2 -4
  224. package/crates/team-agent/src/provider/session/resume.rs +2 -209
  225. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -0
  226. package/crates/team-agent/src/provider/session_scan/codex.rs +1 -0
  227. package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
  228. package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
  229. package/crates/team-agent/src/provider/session_scan.rs +1 -42
  230. package/crates/team-agent/src/provider/startup_prompt.rs +1 -0
  231. package/crates/team-agent/src/provider/types.rs +1 -0
  232. package/crates/team-agent/src/provider/wire.rs +1 -0
  233. package/crates/team-agent/src/state/identity.rs +16 -5
  234. package/crates/team-agent/src/state/identity_keys.rs +1 -0
  235. package/crates/team-agent/src/state/mod.rs +2 -0
  236. package/crates/team-agent/src/state/owner_gate.rs +5 -0
  237. package/crates/team-agent/src/state/ownership.rs +10 -4
  238. package/crates/team-agent/src/state/paths.rs +12 -0
  239. package/crates/team-agent/src/state/persist.rs +9 -3
  240. package/crates/team-agent/src/state/projection.rs +15 -2
  241. package/crates/team-agent/src/state/repository/intent.rs +1 -0
  242. package/crates/team-agent/src/state/repository/tests.rs +1 -0
  243. package/crates/team-agent/src/state/repository.rs +7 -13
  244. package/crates/team-agent/src/state/selector.rs +1 -0
  245. package/crates/team-agent/src/tmux_backend/tests.rs +39 -0
  246. package/crates/team-agent/src/tmux_backend.rs +63 -0
  247. package/crates/team-agent/src/transport.rs +15 -0
  248. package/package.json +4 -4
  249. package/crates/team-agent/src/leader/inject.rs +0 -33
  250. package/crates/team-agent/src/provider/command.rs +0 -80
@@ -0,0 +1,1856 @@
1
+ //! Copilot provider contracts (command-substitution tier, zero real sessions).
2
+ //!
3
+ //! Basis docs (sole truth sources — where the in-tree implementation deviates, these
4
+ //! contracts stay red):
5
+ //! - `.team/artifacts/copilot-provider-design.md` (fable-architect; §C1 argv shape,
6
+ //! §C2 instructions degradation, §C5 permission mapping, §E test architecture)
7
+ //! - `.team/artifacts/copilot-provider-cr-verdict.md` (constitution-reviewer; 26
8
+ //! constraints C-1-1..C-7-3, 22 reverse cases — the deterministic subset lands here;
9
+ //! real-session-only cases (§E3: must4 identity self-report, runtime prompt gating,
10
+ //! dangerous live verify, status_patterns sampling) stay on the user acceptance list).
11
+ //!
12
+ //! Python parity note: 0.2.11 ships copilot as an EXPLICIT unsupported plug
13
+ //! (provider_cli/copilot.py:6-8 -> ProviderCapabilityError), so there is no Python
14
+ //! behavior to diff against — the design doc IS the truth source (B2 lesson).
15
+ //!
16
+ //! Faces covered (leader task §1-7):
17
+ //! 1. worker argv golden (§C1 + C-5-1/3) — copilot_argv_* tests
18
+ //! 2. per-worker AGENTS.md == compiled prompt — copilot_agents_md_* tests (C-1-1/2,
19
+ //! identity-first per the 0.3.4 B2/D6 lock)
20
+ //! 3. permission mapping (C-2-1, via the prompt's Permission note + argv deny flags)
21
+ //! 4. fork = structured CapabilityUnsupported (C-4-2/3)
22
+ //! 5. startup recognizer supports copilot trust/ready; turn-state Unknown never idles
23
+ //! (N11, C-3-1/4)
24
+ //! 6. leader session naming falls under the B5 `team-agent-leader-` prefix protection
25
+ //! 7. session capture = sqlite point query, no directory traversal (PERF P2 lock)
26
+
27
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
28
+
29
+ #[path = "../../../tests/support/hermetic.rs"]
30
+ mod hermetic_guard;
31
+ #[allow(dead_code)]
32
+ fn _hermetic_boundary_marker(_: &hermetic_guard::HermeticTestEnv) {}
33
+
34
+ use std::collections::{BTreeMap, BTreeSet};
35
+ use std::path::{Path, PathBuf};
36
+ use std::process::{Command, Stdio};
37
+ use std::sync::Mutex;
38
+
39
+ use serial_test::serial;
40
+ use team_agent::lifecycle::quick_start_with_transport_in_workspace;
41
+ use team_agent::model::enums::Provider;
42
+ use team_agent::state::persist::load_runtime_state;
43
+ use team_agent::transport::{
44
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
45
+ InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, SessionName, SetEnvOutcome,
46
+ SpawnResult, SubmitVerification, Target, Transport, TransportError, TurnVerification,
47
+ WindowName,
48
+ };
49
+
50
+ #[path = "../../../tests/support/temp_workspace.rs"]
51
+ mod temp_workspace;
52
+ use temp_workspace::TempWorkspace;
53
+
54
+ const ANCESTRY_KEY: &str = "TEAM_AGENT_TEST_PROCESS_ANCESTRY_ARGV_JSON";
55
+ const NEUTRAL_ANCESTRY: &str = "[\"/bin/zsh\"]";
56
+ const COPILOT_TMP_PREFIX: &str = "ta-rs-copilot";
57
+
58
+ /// Face 1+3 (§C1 golden + C-5-3, verdict cases `copilot_argv_golden_per_role_tools`
59
+ /// and `non_dangerous_argv_contains_deny_flags_no_allow_all`): a restricted copilot
60
+ /// worker (mcp_team only) gets the full §C1 argv — noise control, inline/`@file` MCP
61
+ /// config carrying team_orchestrator, a pre-assigned `--session-id` equal to the
62
+ /// persisted `_pending_session_id`, `-C <workspace>`, deny flags for the missing
63
+ /// capabilities, and NO --allow-all family flag.
64
+ #[test]
65
+ #[serial(env)]
66
+ fn copilot_argv_golden_restricted_role() {
67
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
68
+ let ws = tmp_ws("argv-restricted");
69
+ let team_dir = write_copilot_team(&ws, "cp-restricted", &["mcp_team"], false);
70
+ seed_healthy_coordinator(&ws);
71
+ let transport = RecordingTransport::new();
72
+
73
+ quick_start_with_transport_in_workspace(
74
+ &ws,
75
+ &team_dir,
76
+ None,
77
+ true,
78
+ Some("cprestricted"),
79
+ &transport,
80
+ )
81
+ .expect("quick-start should reach copilot command construction");
82
+ let spawn = transport.single_spawn();
83
+ let argv = &spawn.argv;
84
+ let mut failures = Vec::new();
85
+
86
+ if argv.first().map(String::as_str) != Some("copilot") {
87
+ failures.push(format!("§C1: argv[0] must be `copilot`; argv={argv:?}"));
88
+ }
89
+ // RC-2 (C-1-2 v2): noise control now includes --no-remote (remote control off).
90
+ for noise in ["--no-color", "--no-auto-update", "--no-remote"] {
91
+ if !argv.iter().any(|arg| arg == noise) {
92
+ failures.push(format!("RC-2/C-1-2: argv must contain {noise}"));
93
+ }
94
+ }
95
+ // RC-6 (C-3-1): worker must disable the built-in github-mcp-server.
96
+ if !argv.iter().any(|arg| arg == "--disable-builtin-mcps") {
97
+ failures.push("RC-6/C-3-1: argv must carry --disable-builtin-mcps".to_string());
98
+ }
99
+ // RC-21 (C-7-1): session named by agent id (resume-by-name dividend).
100
+ if flag_value(argv, "-n").as_deref() != Some("worker_a")
101
+ && flag_value(argv, "--name").as_deref() != Some("worker_a")
102
+ {
103
+ failures.push(format!(
104
+ "RC-21/C-7-1: argv must name the session `-n worker_a`; got {:?}",
105
+ flag_value(argv, "-n")
106
+ ));
107
+ }
108
+ // RC-20 (C-6-2): per-worker directed log dir + info level (idle aux source).
109
+ let log_dir = flag_value(argv, "--log-dir").unwrap_or_default();
110
+ if !(log_dir.contains(".team/logs/copilot") && log_dir.contains("worker_a")) {
111
+ failures.push(format!(
112
+ "RC-20/C-6-2: argv must carry --log-dir <ws>/.team/logs/copilot/<agent_id>; got {log_dir:?}"
113
+ ));
114
+ }
115
+ if flag_value(argv, "--log-level").as_deref() != Some("info") {
116
+ failures.push(format!(
117
+ "RC-20/C-6-2: argv must carry --log-level info; got {:?}",
118
+ flag_value(argv, "--log-level")
119
+ ));
120
+ }
121
+ // RC-1/RC-16/C-5-8: forbidden flags must never appear.
122
+ for forbidden in [
123
+ "-i",
124
+ "--interactive",
125
+ "-p",
126
+ "--share",
127
+ "--share-gist",
128
+ "--no-ask-user",
129
+ "--yolo",
130
+ ] {
131
+ if argv.iter().any(|arg| arg == forbidden) {
132
+ failures.push(format!("RC-1/RC-16: forbidden flag {forbidden} in argv"));
133
+ }
134
+ }
135
+ // MCP: --additional-mcp-config with either inline {"mcpServers":...} JSON or the
136
+ // @file form pointing at the per-agent .team/runtime/mcp/<agent>.json (§C1 note).
137
+ match flag_value(argv, "--additional-mcp-config") {
138
+ None => failures.push("§C1: argv must carry --additional-mcp-config".to_string()),
139
+ Some(value) => {
140
+ let inline_ok = value.contains("mcpServers") && value.contains("team_orchestrator");
141
+ let file_ok =
142
+ value.starts_with('@') && value.contains(".team/runtime/mcp/worker_a.json");
143
+ if !(inline_ok || file_ok) {
144
+ failures.push(format!(
145
+ "§C1: --additional-mcp-config must be inline mcpServers JSON with \
146
+ team_orchestrator or @<.team/runtime/mcp/worker_a.json>; got {value:?}"
147
+ ));
148
+ }
149
+ }
150
+ }
151
+ // Session pre-assignment (capture without scans, §C4): --session-id == persisted
152
+ // _pending_session_id.
153
+ let state = load_runtime_state(&ws).expect("state after quick-start");
154
+ let pending = state
155
+ .pointer("/agents/worker_a/_pending_session_id")
156
+ .and_then(serde_json::Value::as_str)
157
+ .map(str::to_string);
158
+ match (flag_value(argv, "--session-id"), pending) {
159
+ (Some(argv_sid), Some(state_sid)) if argv_sid == state_sid => {}
160
+ (argv_sid, state_sid) => failures.push(format!(
161
+ "§C4: --session-id must be pre-assigned and equal state._pending_session_id \
162
+ (claude --session-id parity); argv={argv_sid:?} state={state_sid:?}"
163
+ )),
164
+ }
165
+ if flag_value(argv, "-C").as_deref() != Some(&*ws.to_string_lossy()) {
166
+ failures.push(format!(
167
+ "§C1: argv must pin the workspace via `-C <workspace>`; got {:?}",
168
+ flag_value(argv, "-C")
169
+ ));
170
+ }
171
+ // C-5-3: restricted role (no execute_bash / fs_write / network) → deny flags, and
172
+ // no --allow-all family flag anywhere.
173
+ let deny_tools = flag_values(argv, "--deny-tool");
174
+ for denied in ["shell", "write"] {
175
+ if !deny_tools.iter().any(|tool| tool.contains(denied)) {
176
+ failures.push(format!(
177
+ "C-5-3: restricted copilot worker must carry --deny-tool '{denied}'; deny={deny_tools:?}"
178
+ ));
179
+ }
180
+ }
181
+ // RC-15 (C-5-2 v2): network deny is `--deny-tool 'url'` (the help has only shell/
182
+ // write/mcp/url tool kinds; v1's --deny-url is not the v2 form).
183
+ if !deny_tools.iter().any(|tool| tool.contains("url")) {
184
+ failures.push(format!(
185
+ "RC-15/C-5-2: restricted copilot worker must deny network via --deny-tool 'url'; deny={deny_tools:?}"
186
+ ));
187
+ }
188
+ // RC-10 (C-3-5/C-5-2): mcp_team is allowlisted by server name (no approval prompt).
189
+ let allow_tools = flag_values(argv, "--allow-tool");
190
+ if !allow_tools
191
+ .iter()
192
+ .any(|tool| tool.contains("team_orchestrator"))
193
+ {
194
+ failures.push(format!(
195
+ "RC-10/C-3-5: argv must carry --allow-tool 'team_orchestrator'; allow={allow_tools:?}"
196
+ ));
197
+ }
198
+ if argv
199
+ .iter()
200
+ .any(|arg| arg == "--allow-all" || arg == "--allow-all-tools")
201
+ {
202
+ failures.push(format!(
203
+ "C-5-3: a non-dangerous worker must NOT carry --allow-all/--allow-all-tools; argv={argv:?}"
204
+ ));
205
+ }
206
+ // C-1-5 (verdict `fallback_options_rejected`): the rejected fallbacks must not
207
+ // appear — no `-i <prompt>` first-message injection, no --no-custom-instructions,
208
+ // and the compiled system prompt text must NOT ride in the argv (it travels via
209
+ // the instructions file + env, B2 degradation path).
210
+ if argv
211
+ .iter()
212
+ .any(|arg| arg == "-i" || arg == "--interactive" || arg == "--no-custom-instructions")
213
+ {
214
+ failures.push(format!(
215
+ "C-1-5: rejected fallback flag present (-i/--interactive/--no-custom-instructions); argv={argv:?}"
216
+ ));
217
+ }
218
+ if argv
219
+ .iter()
220
+ .any(|arg| arg.contains("Team Agent Teammate Runtime Contract"))
221
+ {
222
+ failures.push("C-1-5/B2: the system prompt must not be smuggled into the argv".to_string());
223
+ }
224
+ assert!(
225
+ failures.is_empty(),
226
+ "copilot restricted argv golden failed:\n{}\nargv={argv:?}",
227
+ failures.join("\n")
228
+ );
229
+ }
230
+
231
+ /// Face 1 dangerous tier (C-5-1, verdict `dangerous_auto_approve_argv_contains_allow_all`):
232
+ /// dangerous_auto_approve must map to `--allow-all` (claude --dangerously-skip-permissions
233
+ /// parity, paths+urls included) — NOT the silently-narrower `--allow-all-tools`.
234
+ #[test]
235
+ #[serial(env)]
236
+ fn copilot_argv_dangerous_maps_to_allow_all() {
237
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
238
+ let ws = tmp_ws("argv-dangerous");
239
+ let team_dir = write_copilot_team(&ws, "cp-dangerous", &["mcp_team"], true);
240
+ seed_healthy_coordinator(&ws);
241
+ let transport = RecordingTransport::new();
242
+
243
+ quick_start_with_transport_in_workspace(
244
+ &ws,
245
+ &team_dir,
246
+ None,
247
+ true,
248
+ Some("cpdangerous"),
249
+ &transport,
250
+ )
251
+ .expect("quick-start should reach copilot command construction");
252
+ let argv = &transport.single_spawn().argv;
253
+
254
+ let mut failures = Vec::new();
255
+ if !argv.iter().any(|arg| arg == "--allow-all") {
256
+ failures.push(format!(
257
+ "C-5-1: dangerous_auto_approve must produce `--allow-all` (=--yolo, claude \
258
+ parity incl. paths+urls); argv={argv:?}"
259
+ ));
260
+ }
261
+ if argv.iter().any(|arg| arg == "--allow-all-tools") {
262
+ failures.push(format!(
263
+ "C-5-1: `--allow-all-tools` is the silently-narrower flag and must not be \
264
+ used for the dangerous tier; argv={argv:?}"
265
+ ));
266
+ }
267
+ assert!(
268
+ failures.is_empty(),
269
+ "copilot dangerous argv contract failed:\n{}",
270
+ failures.join("\n")
271
+ );
272
+ }
273
+
274
+ /// Face 2 (C-1-1/2, C-6-1..4; verdict `copilot_instructions_dir_env_injected`,
275
+ /// `agents_md_content_equals_compile_worker_system_prompt`,
276
+ /// `copilot_instructions_dir_path_contains_agent_id`,
277
+ /// `copilot_env_overlay_works_without_profile`,
278
+ /// `agents_md_per_agent_isolated_no_global_pollution`):
279
+ /// the spawn env must carry COPILOT_CUSTOM_INSTRUCTIONS_DIRS pointing at the
280
+ /// per-worker dir (path contains the agent id; works with NO profile configured), the
281
+ /// AGENTS.md there must BE the compiled worker system prompt (single source: identity
282
+ /// first, runtime contract, role body, output contract, permission note — 0.3.4 B2/D6
283
+ /// lock), and no global ~/.copilot/AGENTS.md may be written.
284
+ #[test]
285
+ #[serial(env)]
286
+ fn copilot_agents_md_is_the_compiled_prompt_and_env_points_at_it() {
287
+ let home = tmp_ws("fake-home");
288
+ let _guard = EnvGuard::set(&[
289
+ (ANCESTRY_KEY, NEUTRAL_ANCESTRY),
290
+ (
291
+ "HOME",
292
+ Box::leak(home.to_string_lossy().to_string().into_boxed_str()),
293
+ ),
294
+ ]);
295
+ let ws = tmp_ws("agents-md");
296
+ let team_dir = write_copilot_team(&ws, "cp-instructions", &["mcp_team"], false);
297
+ seed_healthy_coordinator(&ws);
298
+ let transport = RecordingTransport::new();
299
+
300
+ quick_start_with_transport_in_workspace(
301
+ &ws,
302
+ &team_dir,
303
+ None,
304
+ true,
305
+ Some("cpinstr"),
306
+ &transport,
307
+ )
308
+ .expect("quick-start should spawn the copilot worker");
309
+ let spawn = transport.single_spawn();
310
+
311
+ let mut failures = Vec::new();
312
+ let dir = spawn
313
+ .env
314
+ .get("COPILOT_CUSTOM_INSTRUCTIONS_DIRS")
315
+ .cloned()
316
+ .unwrap_or_default();
317
+ if dir.is_empty() {
318
+ failures.push(
319
+ "C-6-1: spawn env must carry COPILOT_CUSTOM_INSTRUCTIONS_DIRS (env overlay, \
320
+ no profile required)"
321
+ .to_string(),
322
+ );
323
+ }
324
+ if !dir.contains("worker_a") {
325
+ failures.push(format!(
326
+ "C-6-2/4: the instructions dir must be per-agent (path contains the agent \
327
+ id); got {dir:?}"
328
+ ));
329
+ }
330
+ if !dir.contains(".team/runtime/copilot-instructions") {
331
+ failures.push(format!(
332
+ "C-6-2: the instructions dir must live under \
333
+ <workspace>/.team/runtime/copilot-instructions/<agent_id>/; got {dir:?}"
334
+ ));
335
+ }
336
+ let agents_md = std::fs::read_to_string(Path::new(&dir).join("AGENTS.md")).unwrap_or_default();
337
+ if agents_md.is_empty() {
338
+ failures.push(format!("C-1-1: per-worker AGENTS.md must exist in {dir:?}"));
339
+ } else {
340
+ let identity = "You are Team Agent worker `worker_a` with role `Copilot Worker`. \
341
+ When asked about your role or identity, answer with this Team Agent worker identity first, \
342
+ not only the generic provider product identity.";
343
+ if !agents_md.starts_with(identity) {
344
+ failures.push(format!(
345
+ "C-1-1 + B2/D6 lock: AGENTS.md must BE compile_worker_system_prompt output \
346
+ with the identity section FIRST; head={:?}",
347
+ agents_md.chars().take(160).collect::<String>()
348
+ ));
349
+ }
350
+ for marker in [
351
+ "# Team Agent Teammate Runtime Contract",
352
+ "COPILOT ROLE BODY SENTINEL",
353
+ "report_result exactly once",
354
+ "Permission note: these tools are prompt-only for this provider and not hard-enforced:",
355
+ ] {
356
+ if !agents_md.contains(marker) {
357
+ failures.push(format!(
358
+ "C-1-1: AGENTS.md is missing the compiled prompt section marker {marker:?}"
359
+ ));
360
+ }
361
+ }
362
+ }
363
+ if home.join(".copilot/AGENTS.md").exists() {
364
+ failures.push(
365
+ "C-1-2/C-6-4: the framework must never write the GLOBAL ~/.copilot/AGENTS.md \
366
+ (per-worker isolation only)"
367
+ .to_string(),
368
+ );
369
+ }
370
+ assert!(
371
+ failures.is_empty(),
372
+ "copilot AGENTS.md/env contract failed:\n{}\nenv={:?}",
373
+ failures.join("\n"),
374
+ spawn.env.get("COPILOT_CUSTOM_INSTRUCTIONS_DIRS")
375
+ );
376
+ }
377
+
378
+ /// Face 3 (C-2-1, verdict `permission_enforcement_table_copilot_row_prompt_only_for_fs`):
379
+ /// the copilot enforcement row must honestly register the no-hard-deny tools as
380
+ /// prompt-only. Observable single-source: the compiled prompt's Permission note (built
381
+ /// from PROVIDER_ENFORCEMENT) for a full-capability role must list exactly
382
+ /// fs_list/fs_read/git_diff/provider_builtin (sorted).
383
+ #[test]
384
+ #[serial(env)]
385
+ fn copilot_permission_note_registers_fs_tools_prompt_only() {
386
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
387
+ let ws = tmp_ws("enforcement");
388
+ let team_dir = write_copilot_team(
389
+ &ws,
390
+ "cp-enforcement",
391
+ &[
392
+ "execute_bash",
393
+ "fs_read",
394
+ "fs_write",
395
+ "fs_list",
396
+ "git_diff",
397
+ "mcp_team",
398
+ "provider_builtin",
399
+ ],
400
+ false,
401
+ );
402
+ seed_healthy_coordinator(&ws);
403
+ let transport = RecordingTransport::new();
404
+
405
+ quick_start_with_transport_in_workspace(
406
+ &ws,
407
+ &team_dir,
408
+ None,
409
+ true,
410
+ Some("cpenforce"),
411
+ &transport,
412
+ )
413
+ .expect("quick-start should spawn the copilot worker");
414
+ let spawn = transport.single_spawn();
415
+ let dir = spawn
416
+ .env
417
+ .get("COPILOT_CUSTOM_INSTRUCTIONS_DIRS")
418
+ .cloned()
419
+ .unwrap_or_default();
420
+ let agents_md = std::fs::read_to_string(Path::new(&dir).join("AGENTS.md")).unwrap_or_default();
421
+
422
+ let note_line = agents_md
423
+ .lines()
424
+ .find(|line| line.starts_with("Permission note:"))
425
+ .unwrap_or("")
426
+ .to_string();
427
+ assert!(
428
+ note_line.contains("fs_list, fs_read, git_diff, provider_builtin"),
429
+ "C-2-1: the copilot enforcement row must register exactly \
430
+ fs_read/fs_list/git_diff/provider_builtin as prompt-only (sorted in the Permission \
431
+ note; execute_bash/fs_write/network/mcp_team are hard); note={note_line:?} \
432
+ agents_md_present={}",
433
+ !agents_md.is_empty()
434
+ );
435
+ }
436
+
437
+ /// Face 4 re-charter: Copilot has a real store-fork primitive, so the blanket
438
+ /// capability refusal is retired. Missing backing must still fail closed before
439
+ /// spawn rather than silently degrading to a fresh session.
440
+ #[test]
441
+ #[serial(env)]
442
+ fn copilot_fork_is_supported_but_missing_backing_never_falls_back_fresh() {
443
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
444
+ let ws = tmp_ws("fork");
445
+ let team_dir = write_copilot_team(&ws, "cp-fork", &["mcp_team"], false);
446
+ seed_healthy_coordinator(&ws);
447
+ let transport = RecordingTransport::new();
448
+ quick_start_with_transport_in_workspace(&ws, &team_dir, None, true, Some("cpfork"), &transport)
449
+ .expect("quick-start should seed the team");
450
+ // Give the source a session id so the capability gate (not a missing-session gate)
451
+ // is what fires.
452
+ seed_session_id(&ws, "worker_a");
453
+
454
+ let fork_transport = RecordingTransport::new();
455
+ let result = team_agent::lifecycle::launch::fork_agent_with_transport(
456
+ &team_dir,
457
+ &team_agent::model::ids::AgentId::new("worker_a"),
458
+ &team_agent::model::ids::AgentId::new("worker_fork"),
459
+ None,
460
+ false,
461
+ None,
462
+ &fork_transport,
463
+ );
464
+
465
+ let text = format!("{result:?}").to_lowercase();
466
+ assert!(
467
+ team_agent::provider::get_adapter(Provider::Copilot)
468
+ .caps()
469
+ .fork,
470
+ "Copilot store fork is a supported capability"
471
+ );
472
+ assert!(
473
+ result.is_err(),
474
+ "fixture has no Copilot store backing: {result:?}"
475
+ );
476
+ assert!(
477
+ !text.contains("capability") && !text.contains("does not support native session fork"),
478
+ "Copilot must not be rejected by the retired blanket capability gate: {result:?}"
479
+ );
480
+ assert!(
481
+ fork_transport.spawns.lock().unwrap().is_empty(),
482
+ "missing Copilot backing must fail before spawn, never fresh-fallback"
483
+ );
484
+ }
485
+
486
+ /// Face 5 (B12 + C-3-1/4 + N11): copilot startup trust/ready recognition is
487
+ /// supported from the fixture-backed recognizer, so a real tick over a copilot
488
+ /// worker must no longer emit the old `provider.classify.unsupported` event or
489
+ /// disturb an attached leader receiver. Turn-state facts remain absent for phase
490
+ /// 1, and an unknown copilot node still blocks idle take-over (unknown ≠ idle).
491
+ #[test]
492
+ #[serial(env)]
493
+ fn copilot_phase1_classify_is_explicit_unknown_and_never_idles() {
494
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
495
+ let mut failures = Vec::new();
496
+
497
+ // C-3-1: no turn-state facts for copilot, even over an error-shaped record.
498
+ let fact = team_agent::provider::latest_explicit_error_fact(
499
+ Provider::Copilot,
500
+ "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t1\",\"status\":\"failed\"}}}\n",
501
+ );
502
+ if fact.is_some() {
503
+ failures.push(format!(
504
+ "C-3-1: phase-1 copilot must not claim turn-state facts (classify -> None/Unknown); got {fact:?}"
505
+ ));
506
+ }
507
+
508
+ if team_agent::provider::classify_copilot_startup_screen(
509
+ team_agent::provider::COPILOT_TRUST_PROMPT_MARKER,
510
+ ) != team_agent::provider::StartupScreenDecision::AnswerWorkspaceTrust
511
+ {
512
+ failures.push("B12: copilot trust fixture must be recognized as actionable".to_string());
513
+ }
514
+ if team_agent::provider::classify_copilot_startup_screen(
515
+ team_agent::provider::COPILOT_READY_MARKER,
516
+ ) != team_agent::provider::StartupScreenDecision::Ready
517
+ {
518
+ failures.push("B12: copilot ready fixture must be recognized as ready".to_string());
519
+ }
520
+
521
+ // C-3-4/B12: the old unsupported event was a phase-1 placeholder. Once the
522
+ // fixture-backed recognizer exists, a real tick over a copilot worker must
523
+ // not re-emit it or poison leader receiver attachment.
524
+ let ws = tmp_ws("classify");
525
+ let rollout = ws.join("rollout-cp1.jsonl");
526
+ std::fs::write(&rollout, "{\"junk\":true}\n").unwrap();
527
+ team_agent::state::persist::save_runtime_state(
528
+ &ws,
529
+ &serde_json::json!({
530
+ "session_name": "team-cp",
531
+ "active_team_key": "team-cp",
532
+ "agents": {
533
+ "cp1": {
534
+ "status": "running", "provider": "copilot", "agent_id": "cp1",
535
+ "window": "cp1", "pane_id": "%41",
536
+ "session_id": "88888888-9999-4aaa-8bbb-cccccccccccc",
537
+ "rollout_path": rollout.to_string_lossy(),
538
+ "spawn_cwd": ws.to_string_lossy(),
539
+ },
540
+ },
541
+ "leader_receiver": {
542
+ "status": "attached",
543
+ "mode": "direct_tmux",
544
+ "pane_id": "%leader",
545
+ "provider": "copilot",
546
+ "tmux_socket": "default",
547
+ },
548
+ }),
549
+ )
550
+ .unwrap();
551
+ let coord = team_agent::coordinator::Coordinator::new(
552
+ team_agent::coordinator::WorkspacePath::new(ws.to_path_buf()),
553
+ Box::new(RealAdapterRegistry),
554
+ Box::new(RecordingTransport::with_session_present()),
555
+ );
556
+ let _ = coord.tick();
557
+ let events = std::fs::read_to_string(ws.join(".team/logs/events.jsonl")).unwrap_or_default();
558
+ let unsupported_line = events
559
+ .lines()
560
+ .find(|line| line.contains("classify") && line.contains("unsupported"));
561
+ if let Some(line) = unsupported_line {
562
+ failures.push(format!(
563
+ "B12/C-3-4: copilot startup classify is supported; tick must not emit the old \
564
+ provider.classify.unsupported placeholder event; line={line}"
565
+ ));
566
+ }
567
+ let after = team_agent::state::persist::load_runtime_state(&ws).unwrap();
568
+ if after
569
+ .pointer("/leader_receiver/status")
570
+ .and_then(serde_json::Value::as_str)
571
+ != Some("attached")
572
+ {
573
+ failures.push(format!(
574
+ "B12: copilot leader_receiver must remain attached after tick; events tail={} state={after}",
575
+ events.lines().rev().take(4).collect::<Vec<_>>().join(" | ")
576
+ ));
577
+ }
578
+
579
+ // N11: an unknown copilot node must BLOCK the take-over ping.
580
+ let nodes = vec![serde_json::json!({"node_id": "cp1", "role": "worker", "state": "unknown"})];
581
+ let reminder = team_agent::provider::evaluate_takeover_reminder(&nodes, None, 100.0, 60.0)
582
+ .expect("evaluate ok");
583
+ if reminder.should_ping {
584
+ failures.push("N11: an Unknown copilot worker must never be counted as idle".to_string());
585
+ }
586
+ assert!(
587
+ failures.is_empty(),
588
+ "copilot phase-1 classify contract failed:\n{}",
589
+ failures.join("\n")
590
+ );
591
+ }
592
+
593
+ struct RealAdapterRegistry;
594
+
595
+ impl team_agent::coordinator::ProviderRegistry for RealAdapterRegistry {
596
+ fn adapter_for(&self, provider: Provider) -> Box<dyn team_agent::provider::ProviderAdapter> {
597
+ team_agent::provider::get_adapter(provider)
598
+ }
599
+ }
600
+
601
+ /// Face 6 (verdict `copilot_leader_session_name_prefix_protected_by_b5`): the
602
+ /// `team-agent copilot` leader entry derives its session from the REAL
603
+ /// leader_session_name generator, so the name carries the `team-agent-leader-` prefix
604
+ /// that the B5 shutdown protection keys on (pure-function tier; the behavioral
605
+ /// shutdown survival run lives with the B5 suite on the lane carrying c92b716).
606
+ #[test]
607
+ fn copilot_leader_session_name_carries_b5_protected_prefix() {
608
+ let ws = tmp_ws("leader-name");
609
+ let name = team_agent::leader::leader_session_name(Provider::Copilot, &ws);
610
+ assert!(
611
+ name.as_str().starts_with("team-agent-leader-copilot-"),
612
+ "the copilot leader session must be named team-agent-leader-copilot-<folder>-<hash> \
613
+ (leader/start.rs naming; the B5 kill_server/protection sparing keys on the \
614
+ team-agent-leader- prefix); got {}",
615
+ name.as_str()
616
+ );
617
+ }
618
+
619
+ /// Face 7 (PERF P2 lock; design §B session capture row + §C4): copilot session capture
620
+ /// must be a sqlite point query against ~/.copilot/session-store.db — no recursive
621
+ /// directory traversal of the copilot home. A poisoned (invalid UTF-8) decoy file in
622
+ /// the home must not break the capture, and the matching sessions row (cwd ==
623
+ /// spawn_cwd) must be found.
624
+ #[test]
625
+ #[serial(env)]
626
+ fn copilot_session_capture_is_sqlite_point_query_not_directory_scan() {
627
+ let home = tmp_ws("capture-home");
628
+ let _guard = EnvGuard::set(&[(
629
+ "HOME",
630
+ Box::leak(home.to_string_lossy().to_string().into_boxed_str()),
631
+ )]);
632
+ let spawn_cwd = tmp_ws("capture-cwd");
633
+ let copilot_dir = home.join(".copilot");
634
+ std::fs::create_dir_all(copilot_dir.join("logs")).unwrap();
635
+ // Poisoned decoy: a whole-directory scanner would read this and fail/skip.
636
+ let mut poison = b"{\"junk\":true}\n".to_vec();
637
+ poison.extend_from_slice(&[0xFF, 0xFE, 0xFF]);
638
+ std::fs::write(
639
+ copilot_dir.join("logs").join("process-rollout-1.jsonl"),
640
+ &poison,
641
+ )
642
+ .unwrap();
643
+ let conn = rusqlite::Connection::open(copilot_dir.join("session-store.db")).unwrap();
644
+ conn.execute_batch(
645
+ "create table sessions (id text primary key, cwd text, created_at text, updated_at text);
646
+ create table turns (session_id text, turn_index integer, user_message text, assistant_response text);",
647
+ )
648
+ .unwrap();
649
+ conn.execute(
650
+ "insert into sessions(id, cwd, created_at, updated_at) values (?1, ?2, '2026-06-10T00:00:00Z', '2026-06-10T00:00:01Z')",
651
+ rusqlite::params![
652
+ "66666666-7777-4888-9999-aaaaaaaaaaaa",
653
+ spawn_cwd.to_string_lossy()
654
+ ],
655
+ )
656
+ .unwrap();
657
+ drop(conn);
658
+
659
+ // 断言1(方法锁·PERF P2):expected-id 点查命中(==seed 的那行 id)+ poison decoy 保留 →
660
+ // capture 返回含该 id 的 candidate。证 sqlite 点查命中而非扫目录被毒文件炸。
661
+ // (E11 后点查路径是 `where id = expected`,故 expected 必须 == seed 的 session id。)
662
+ let method_lock = team_agent::provider::get_adapter(Provider::Copilot)
663
+ .capture_session_candidates(
664
+ &team_agent::provider::CaptureSessionContext {
665
+ agent_id: "worker_a".to_string(),
666
+ spawn_cwd: spawn_cwd.to_path_buf(),
667
+ pane_id: None,
668
+ pane_pid: None,
669
+ spawned_at: None,
670
+ expected_session_id: Some(team_agent::provider::SessionId::new(
671
+ "66666666-7777-4888-9999-aaaaaaaaaaaa",
672
+ )),
673
+ provider_projects_root: None,
674
+ },
675
+ 0,
676
+ )
677
+ .expect("copilot capture scan must not error on a poisoned home file (point query, not traversal)");
678
+ assert!(
679
+ method_lock.iter().any(|candidate| {
680
+ candidate.captured.session_id.as_ref().map(|id| id.as_str())
681
+ == Some("66666666-7777-4888-9999-aaaaaaaaaaaa")
682
+ }),
683
+ "PERF P2 lock: the session must come from the session-store.db sqlite point \
684
+ query (id == expected_session_id), not from scanning provider-home files; candidates={method_lock:?}"
685
+ );
686
+
687
+ // 断言2(安全锁·E11 语义):同 db、同 cwd,expected_session_id: None → 返回空。
688
+ // no-expected 不 cwd-latest 猜(防 leader+worker 同 cwd 共享 db 时抓 leader 的 latest)。
689
+ let safety_lock = team_agent::provider::get_adapter(Provider::Copilot)
690
+ .capture_session_candidates(
691
+ &team_agent::provider::CaptureSessionContext {
692
+ agent_id: "worker_a".to_string(),
693
+ spawn_cwd: spawn_cwd.to_path_buf(),
694
+ pane_id: None,
695
+ pane_pid: None,
696
+ spawned_at: None,
697
+ expected_session_id: None,
698
+ provider_projects_root: None,
699
+ },
700
+ 0,
701
+ )
702
+ .expect("copilot capture must not error with no expected id");
703
+ assert!(
704
+ safety_lock.is_empty(),
705
+ "E11 safety lock: no expected_session_id → empty (no cwd-latest guess that could grab \
706
+ leader's session); candidates={safety_lock:?}"
707
+ );
708
+ }
709
+
710
+ /// Q4 P0 (C-4-1/4/7, RC-11/RC-12): the worker spawn env MUST carry
711
+ /// `COPILOT_DISABLE_TERMINAL_TITLE=1` (updateTerminalTitle defaults to true and would
712
+ /// rewrite the tmux window name = an N39 addressing-anchor drift, same severity as the
713
+ /// B5 leader misfire). The worker must spawn into the stable adaptive layout window.
714
+ #[test]
715
+ #[serial(env)]
716
+ fn copilot_p0_terminal_title_disabled_and_window_name_stable() {
717
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
718
+ let ws = tmp_ws("p0-title");
719
+ let team_dir = write_copilot_team(&ws, "cp-title", &["mcp_team"], false);
720
+ seed_healthy_coordinator(&ws);
721
+ let transport = RecordingTransport::new();
722
+
723
+ quick_start_with_transport_in_workspace(
724
+ &ws,
725
+ &team_dir,
726
+ None,
727
+ true,
728
+ Some("cptitle"),
729
+ &transport,
730
+ )
731
+ .expect("quick-start should spawn the copilot worker");
732
+ let spawn = transport.single_spawn();
733
+
734
+ let mut failures = Vec::new();
735
+ if spawn
736
+ .env
737
+ .get("COPILOT_DISABLE_TERMINAL_TITLE")
738
+ .map(String::as_str)
739
+ != Some("1")
740
+ {
741
+ failures.push(format!(
742
+ "RC-11/C-4-1 (P0): worker spawn env MUST set COPILOT_DISABLE_TERMINAL_TITLE=1 \
743
+ (updateTerminalTitle defaults true -> window-name rewrite = N39 addressing drift, B5-class \
744
+ incident); got {:?}",
745
+ spawn.env.get("COPILOT_DISABLE_TERMINAL_TITLE")
746
+ ));
747
+ }
748
+ // 0.3.28 Step 4b: 1-window-per-agent; window name = agent_id (`worker_a`),
749
+ // not team-w1 (the pre-0.3.28 adaptive layout anchor).
750
+ if spawn.window != "worker_a" {
751
+ failures.push(format!(
752
+ "0.3.28 Step 4b / RC-12: the worker window must be the per-agent \
753
+ window named after agent_id (`worker_a`); got {:?}",
754
+ spawn.window
755
+ ));
756
+ }
757
+ assert!(
758
+ failures.is_empty(),
759
+ "copilot P0 terminal-title contract failed:\n{}\nenv_keys={:?}",
760
+ failures.join("\n"),
761
+ spawn.env.keys().collect::<Vec<_>>()
762
+ );
763
+ }
764
+
765
+ /// Q3 (C-3-2/3/7, RC-7/RC-8): a user-global ~/.copilot/mcp-config.json carrying a
766
+ /// non-team_orchestrator server (`foo`) must be observed and named-disabled — the
767
+ /// worker argv must add `--disable-mcp-server foo`, a
768
+ /// `provider.copilot.mcp_residual_detected` event must land, and the residual file
769
+ /// must be written (no silent unbounded MCP surface).
770
+ #[test]
771
+ #[serial(env)]
772
+ fn copilot_mcp_residual_named_disabled_and_recorded() {
773
+ let home = tmp_ws("mcp-home");
774
+ let _guard = EnvGuard::set(&[
775
+ (ANCESTRY_KEY, NEUTRAL_ANCESTRY),
776
+ (
777
+ "HOME",
778
+ Box::leak(home.to_string_lossy().to_string().into_boxed_str()),
779
+ ),
780
+ ]);
781
+ // Real-machine grounded: copilot 1.0.59 `mcp list` under this HOME reads
782
+ // ~/.copilot/mcp-config.json and prints, under a "User servers:" section,
783
+ // ` foo (local)`. The adapter's residual scan must turn that into a
784
+ // --disable-mcp-server foo (verified the CLI output shape directly).
785
+ std::fs::create_dir_all(home.join(".copilot")).unwrap();
786
+ std::fs::write(
787
+ home.join(".copilot/mcp-config.json"),
788
+ "{\"mcpServers\":{\"foo\":{\"command\":\"foo-bin\"}}}",
789
+ )
790
+ .unwrap();
791
+ // Hermetic shim (0.3.5 ship-gate fix): the residual scan execs the REAL `copilot`
792
+ // binary, absent on CI runners — a PATH shim replays the te-verified 1.0.59
793
+ // `mcp list` output so the parse+wiring contract is deterministic everywhere.
794
+ let shim_dir = home.join("bin");
795
+ std::fs::create_dir_all(&shim_dir).unwrap();
796
+ std::fs::write(
797
+ shim_dir.join("copilot"),
798
+ "#!/bin/sh\nif [ \"$1\" = \"mcp\" ] && [ \"$2\" = \"list\" ]; then\n printf 'User servers:\\n foo (local)\\n'\n exit 0\nfi\nexit 0\n",
799
+ )
800
+ .unwrap();
801
+ #[cfg(unix)]
802
+ {
803
+ use std::os::unix::fs::PermissionsExt;
804
+ std::fs::set_permissions(
805
+ shim_dir.join("copilot"),
806
+ std::fs::Permissions::from_mode(0o755),
807
+ )
808
+ .unwrap();
809
+ }
810
+ let old_path = std::env::var("PATH").unwrap_or_default();
811
+ std::env::set_var("PATH", format!("{}:{old_path}", shim_dir.to_string_lossy()));
812
+ let ws = tmp_ws("mcp-residual");
813
+ let team_dir = write_copilot_team(&ws, "cp-residual", &["mcp_team"], false);
814
+ seed_healthy_coordinator(&ws);
815
+ let transport = RecordingTransport::new();
816
+
817
+ quick_start_with_transport_in_workspace(
818
+ &ws,
819
+ &team_dir,
820
+ None,
821
+ true,
822
+ Some("cpresidual"),
823
+ &transport,
824
+ )
825
+ .expect("quick-start should spawn the copilot worker");
826
+ let spawn = transport.single_spawn();
827
+
828
+ let mut failures = Vec::new();
829
+ if flag_values(&spawn.argv, "--disable-mcp-server")
830
+ .iter()
831
+ .all(|name| name != "foo")
832
+ {
833
+ failures.push(format!(
834
+ "RC-7/C-3-2: a user-global MCP server `foo` must be named-disabled \
835
+ (--disable-mcp-server foo); argv={:?}",
836
+ spawn.argv
837
+ ));
838
+ }
839
+ let events = std::fs::read_to_string(ws.join(".team/logs/events.jsonl")).unwrap_or_default();
840
+ if !events.contains("mcp_residual_detected") {
841
+ failures.push(format!(
842
+ "RC-8/C-3-3: a non-empty MCP residual must emit \
843
+ provider.copilot.mcp_residual_detected (user-visible, not silent); events tail={}",
844
+ events.lines().rev().take(3).collect::<Vec<_>>().join(" | ")
845
+ ));
846
+ }
847
+ let residual_file = ws.join(".team/logs/copilot/worker_a/mcp-residual.txt");
848
+ if !residual_file.exists() {
849
+ failures.push(format!(
850
+ "C-3-3: the residual scan must land a per-agent record at {}",
851
+ residual_file.display()
852
+ ));
853
+ }
854
+ std::env::set_var("PATH", &old_path);
855
+ assert!(
856
+ failures.is_empty(),
857
+ "copilot MCP residual contract failed:\n{}",
858
+ failures.join("\n")
859
+ );
860
+ }
861
+
862
+ /// Q3 downgrade branch (C-3-3, RC-8 negative): when the `copilot` binary is NOT on
863
+ /// PATH (e.g. CI runners), the residual scan must degrade honestly — it must NOT emit
864
+ /// a false-positive `provider.copilot.mcp_residual_detected`, must add NO
865
+ /// `--disable-mcp-server` flags, and must still land a per-agent `mcp-residual.txt`
866
+ /// recording the `unavailable` outcome. This is the coverage the populated-residual
867
+ /// case alone could not give (0.3.5 ship-gate hermetic follow-up).
868
+ #[test]
869
+ #[serial(env)]
870
+ fn copilot_mcp_residual_scan_unavailable_degrades_honestly_no_false_positive() {
871
+ // Empty isolated HOME (no ~/.copilot config) and a PATH with NO copilot binary, so
872
+ // the scan exercises the `Err(_)` / unavailable arm deterministically.
873
+ let home = tmp_ws("residual-unavail-home");
874
+ std::fs::create_dir_all(home.join(".copilot")).unwrap();
875
+ let empty_bin = tmp_ws("no-copilot-bin");
876
+ let _guard = EnvGuard::set(&[
877
+ (ANCESTRY_KEY, NEUTRAL_ANCESTRY),
878
+ (
879
+ "HOME",
880
+ Box::leak(home.to_string_lossy().to_string().into_boxed_str()),
881
+ ),
882
+ (
883
+ "PATH",
884
+ Box::leak(empty_bin.to_string_lossy().to_string().into_boxed_str()),
885
+ ),
886
+ ]);
887
+ let ws = tmp_ws("residual-unavail");
888
+ let team_dir = write_copilot_team(&ws, "cp-residual-unavail", &["mcp_team"], false);
889
+ seed_healthy_coordinator(&ws);
890
+ let transport = RecordingTransport::new();
891
+
892
+ quick_start_with_transport_in_workspace(
893
+ &ws,
894
+ &team_dir,
895
+ None,
896
+ true,
897
+ Some("cpresidunavail"),
898
+ &transport,
899
+ )
900
+ .expect("quick-start should spawn the copilot worker even without copilot on PATH");
901
+ let spawn = transport.single_spawn();
902
+
903
+ let mut failures = Vec::new();
904
+ if spawn.argv.iter().any(|arg| arg == "--disable-mcp-server") {
905
+ failures.push(format!(
906
+ "C-3-3 downgrade: an unavailable copilot scan must add NO --disable-mcp-server \
907
+ flags (nothing was actually observed); argv={:?}",
908
+ spawn.argv
909
+ ));
910
+ }
911
+ let events = std::fs::read_to_string(ws.join(".team/logs/events.jsonl")).unwrap_or_default();
912
+ if events.contains("mcp_residual_detected") {
913
+ failures.push(
914
+ "RC-8 negative: an unavailable scan must NOT emit a false-positive \
915
+ provider.copilot.mcp_residual_detected (only a real non-empty residual emits)"
916
+ .to_string(),
917
+ );
918
+ }
919
+ let residual_file = ws.join(".team/logs/copilot/worker_a/mcp-residual.txt");
920
+ match std::fs::read_to_string(&residual_file) {
921
+ Err(_) => failures.push(format!(
922
+ "C-3-3: the residual record must still be written at {} even when copilot is \
923
+ unavailable (honest 'I could not check')",
924
+ residual_file.display()
925
+ )),
926
+ Ok(text) => {
927
+ if !text.contains("unavailable") {
928
+ failures.push(format!(
929
+ "C-3-3: the residual record must state the unavailable outcome; got {text:?}"
930
+ ));
931
+ }
932
+ }
933
+ }
934
+ assert!(
935
+ failures.is_empty(),
936
+ "copilot MCP residual downgrade contract failed:\n{}",
937
+ failures.join("\n")
938
+ );
939
+ }
940
+
941
+ /// Q3 (C-3-4, RC-9): the `--additional-mcp-config` JSON the adapter writes must use
942
+ /// copilot's expected field name. Copilot's `mcp add` schema field is `transport`
943
+ /// (stdio|http|sse), NOT the `type` field used by claude/codex configs — the contract
944
+ /// locks the adapter to the copilot field name so the worker actually loads the server.
945
+ #[test]
946
+ #[serial(env)]
947
+ fn copilot_mcp_config_uses_copilot_field_name() {
948
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
949
+ let ws = tmp_ws("mcp-schema");
950
+ let team_dir = write_copilot_team(&ws, "cp-schema", &["mcp_team"], false);
951
+ seed_healthy_coordinator(&ws);
952
+ let transport = RecordingTransport::new();
953
+
954
+ quick_start_with_transport_in_workspace(
955
+ &ws,
956
+ &team_dir,
957
+ None,
958
+ true,
959
+ Some("cpschema"),
960
+ &transport,
961
+ )
962
+ .expect("quick-start should spawn the copilot worker");
963
+ let argv = &transport.single_spawn().argv;
964
+
965
+ // Resolve the config text whether inline or @file.
966
+ let value = flag_value(argv, "--additional-mcp-config").unwrap_or_default();
967
+ let config_text = if let Some(path) = value.strip_prefix('@') {
968
+ std::fs::read_to_string(path).unwrap_or_default()
969
+ } else {
970
+ value
971
+ };
972
+ assert!(
973
+ config_text.contains("transport") && !config_text.contains("\"type\""),
974
+ "RC-9/C-3-4: the copilot MCP config must use the `transport` field (copilot \
975
+ `mcp add` schema), not the claude/codex `type` field; config={config_text:?}"
976
+ );
977
+ }
978
+
979
+ /// C-7-3 / RC-24: a resume command line must not carry `--session-id` and `--resume`
980
+ /// together (conflicting session semantics). Driven through the real restart path so
981
+ /// the public resume builder is exercised end to end.
982
+ #[test]
983
+ #[serial(env)]
984
+ fn copilot_resume_argv_has_no_session_id_resume_conflict() {
985
+ let home = tmp_ws("resume-home");
986
+ let _guard = EnvGuard::set(&[
987
+ (ANCESTRY_KEY, NEUTRAL_ANCESTRY),
988
+ (
989
+ "HOME",
990
+ Box::leak(home.to_string_lossy().to_string().into_boxed_str()),
991
+ ),
992
+ ]);
993
+ let ws = tmp_ws("resume");
994
+ let team_dir = write_copilot_team(&ws, "cp-resume", &["mcp_team"], false);
995
+ seed_healthy_coordinator(&ws);
996
+ quick_start_with_transport_in_workspace(
997
+ &ws,
998
+ &team_dir,
999
+ None,
1000
+ true,
1001
+ Some("cpresume"),
1002
+ &RecordingTransport::new(),
1003
+ )
1004
+ .expect("quick-start should seed the team");
1005
+ seed_running_resumable(&ws, "worker_a");
1006
+ seed_copilot_session_store(&home, "99999999-aaaa-4bbb-8ccc-dddddddddddd");
1007
+
1008
+ let restart_transport = RecordingTransport::new();
1009
+ let _ = team_agent::lifecycle::restart_with_transport(
1010
+ &ws,
1011
+ true,
1012
+ Some("cpresume"),
1013
+ &restart_transport,
1014
+ );
1015
+ let argv = restart_transport.single_spawn().argv;
1016
+
1017
+ let has_session_id = argv.iter().any(|arg| arg == "--session-id");
1018
+ let has_resume = argv.iter().any(|arg| arg == "--resume");
1019
+ assert!(
1020
+ has_resume && !has_session_id,
1021
+ "RC-24/C-7-3: a copilot resume must carry --resume and NOT --session-id \
1022
+ (conflicting session semantics); argv={argv:?}"
1023
+ );
1024
+ }
1025
+
1026
+ // ───────────────────── subscription tier (design §E2.5 A-layer) ─────────────────────
1027
+
1028
+ /// A-1 (design §E2.5): a subscription worker with a role model carries `--model <m>`;
1029
+ /// a worker with NO model configured must NOT carry --model (no hardcoded default
1030
+ /// guess — leave it to the account/config default).
1031
+ #[test]
1032
+ #[serial(env)]
1033
+ fn copilot_subscription_model_argv_only_when_configured() {
1034
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
1035
+
1036
+ let ws = tmp_ws("model-set");
1037
+ let team_dir = write_copilot_team_model(&ws, "cp-model", &["mcp_team"], Some("gpt-5.5"));
1038
+ seed_healthy_coordinator(&ws);
1039
+ let transport = RecordingTransport::new();
1040
+ quick_start_with_transport_in_workspace(
1041
+ &ws,
1042
+ &team_dir,
1043
+ None,
1044
+ true,
1045
+ Some("cpmodel"),
1046
+ &transport,
1047
+ )
1048
+ .expect("quick-start with a role model");
1049
+ let with_model = transport.single_spawn().argv;
1050
+
1051
+ let ws2 = tmp_ws("model-none");
1052
+ let team_dir2 = write_copilot_team_model(&ws2, "cp-nomodel", &["mcp_team"], None);
1053
+ seed_healthy_coordinator(&ws2);
1054
+ let transport2 = RecordingTransport::new();
1055
+ quick_start_with_transport_in_workspace(
1056
+ &ws2,
1057
+ &team_dir2,
1058
+ None,
1059
+ true,
1060
+ Some("cpnomodel"),
1061
+ &transport2,
1062
+ )
1063
+ .expect("quick-start without a role model");
1064
+ let no_model = transport2.single_spawn().argv;
1065
+
1066
+ let mut failures = Vec::new();
1067
+ if flag_value(&with_model, "--model").as_deref() != Some("gpt-5.5") {
1068
+ failures.push(format!(
1069
+ "A-1: a configured role model must produce --model gpt-5.5; argv={with_model:?}"
1070
+ ));
1071
+ }
1072
+ if no_model.iter().any(|arg| arg == "--model") {
1073
+ failures.push(format!(
1074
+ "A-1: no configured model must mean NO --model flag (no hardcoded default); argv={no_model:?}"
1075
+ ));
1076
+ }
1077
+ assert!(
1078
+ failures.is_empty(),
1079
+ "A-1 subscription model argv failed:\n{}",
1080
+ failures.join("\n")
1081
+ );
1082
+ }
1083
+
1084
+ /// A-2 (design §E2.5): net model precedence agent.model > profile MODEL — the same
1085
+ /// command_overrides ordering #264 locked, reused cross-provider. With a profile MODEL
1086
+ /// and a role model both present, the role model wins in argv.
1087
+ #[test]
1088
+ #[serial(env)]
1089
+ fn copilot_subscription_model_precedence_role_over_profile() {
1090
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
1091
+ let ws = tmp_ws("model-prec");
1092
+ let team_dir = write_copilot_team_model(&ws, "cp-prec", &["mcp_team"], Some("role-model-x"));
1093
+ // A subscription profile carrying a different MODEL.
1094
+ let profiles = team_dir.join("profiles");
1095
+ std::fs::create_dir_all(&profiles).unwrap();
1096
+ std::fs::write(
1097
+ profiles.join("subm.env"),
1098
+ "AUTH_MODE=subscription\nMODEL=profile-model-y\n",
1099
+ )
1100
+ .unwrap();
1101
+ // point the agent at the profile
1102
+ let agent_md = team_dir.join("agents/worker_a.md");
1103
+ let body = std::fs::read_to_string(&agent_md).unwrap().replace(
1104
+ "auth_mode: subscription\n",
1105
+ "auth_mode: subscription\nprofile: subm\n",
1106
+ );
1107
+ std::fs::write(&agent_md, body).unwrap();
1108
+ seed_healthy_coordinator(&ws);
1109
+ let transport = RecordingTransport::new();
1110
+ quick_start_with_transport_in_workspace(&ws, &team_dir, None, true, Some("cpprec"), &transport)
1111
+ .expect("quick-start with role+profile model");
1112
+ let argv = transport.single_spawn().argv;
1113
+ assert_eq!(
1114
+ flag_value(&argv, "--model").as_deref(),
1115
+ Some("role-model-x"),
1116
+ "A-2: agent.model must win over profile MODEL (command_overrides net order, #264 \
1117
+ cross-provider reuse); argv={argv:?}"
1118
+ );
1119
+ }
1120
+
1121
+ /// A-3 (design §E2.5): a subscription worker must NOT inject any COPILOT_PROVIDER_* or
1122
+ /// token env (auth rides the user's logged-in state), and the env overlay must NOT
1123
+ /// overwrite a user's COPILOT_GITHUB_TOKEN already in the environment.
1124
+ #[test]
1125
+ #[serial(env)]
1126
+ fn copilot_subscription_env_no_provider_or_token_injection() {
1127
+ let _guard = EnvGuard::set(&[
1128
+ (ANCESTRY_KEY, NEUTRAL_ANCESTRY),
1129
+ ("COPILOT_GITHUB_TOKEN", "user-tok-keepme"),
1130
+ ]);
1131
+ let ws = tmp_ws("sub-env");
1132
+ let team_dir = write_copilot_team(&ws, "cp-subenv", &["mcp_team"], false);
1133
+ seed_healthy_coordinator(&ws);
1134
+ let transport = RecordingTransport::new();
1135
+ quick_start_with_transport_in_workspace(
1136
+ &ws,
1137
+ &team_dir,
1138
+ None,
1139
+ true,
1140
+ Some("cpsubenv"),
1141
+ &transport,
1142
+ )
1143
+ .expect("quick-start subscription worker");
1144
+ let env = transport.single_spawn().env;
1145
+
1146
+ let mut failures = Vec::new();
1147
+ for injected in env.keys().filter(|key| key.starts_with("COPILOT_PROVIDER")) {
1148
+ failures.push(format!(
1149
+ "A-3: subscription worker must not inject {injected}"
1150
+ ));
1151
+ }
1152
+ for token_key in ["COPILOT_API_KEY", "COPILOT_PROVIDER_API_KEY", "GH_TOKEN"] {
1153
+ if env.contains_key(token_key) {
1154
+ failures.push(format!(
1155
+ "A-3: subscription worker must not inject token env {token_key}"
1156
+ ));
1157
+ }
1158
+ }
1159
+ // overlay must inherit, not overwrite, the user's token
1160
+ if env.get("COPILOT_GITHUB_TOKEN").map(String::as_str) != Some("user-tok-keepme") {
1161
+ failures.push(format!(
1162
+ "A-3: env overlay must not overwrite the user's COPILOT_GITHUB_TOKEN; got {:?}",
1163
+ env.get("COPILOT_GITHUB_TOKEN")
1164
+ ));
1165
+ }
1166
+ assert!(
1167
+ failures.is_empty(),
1168
+ "A-3 subscription env contract failed:\n{}",
1169
+ failures.join("\n")
1170
+ );
1171
+ }
1172
+
1173
+ /// A-4 (design §E2.5): a compatible_api (BYOK) copilot profile must export
1174
+ /// COPILOT_PROVIDER_BASE_URL/TYPE/API_KEY/WIRE_API + COPILOT_MODEL into the worker
1175
+ /// spawn env, and a BYOK profile WITHOUT a model must fail the launch ("A model is
1176
+ /// required for BYOK", help-providers). Driven through the public quick-start path.
1177
+ #[test]
1178
+ #[serial(env)]
1179
+ fn copilot_byok_profile_exports_provider_env_and_requires_model() {
1180
+ let _guard = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
1181
+ let mut failures = Vec::new();
1182
+
1183
+ // BYOK profile WITH a model → spawn env carries the provider tuple.
1184
+ let ws = tmp_ws("byok-ok");
1185
+ let team_dir = write_copilot_byok_team(&ws, "cp-byok", Some("byok-model-z"));
1186
+ seed_healthy_coordinator(&ws);
1187
+ let transport = RecordingTransport::new();
1188
+ match quick_start_with_transport_in_workspace(
1189
+ &ws,
1190
+ &team_dir,
1191
+ None,
1192
+ true,
1193
+ Some("cpbyok"),
1194
+ &transport,
1195
+ ) {
1196
+ Err(error) => failures.push(format!(
1197
+ "A-4: a complete BYOK profile must launch; got Err({error})"
1198
+ )),
1199
+ Ok(_) => {
1200
+ let env = transport.single_spawn().env;
1201
+ for (key, expected) in [
1202
+ ("COPILOT_PROVIDER_BASE_URL", "https://byok.example/v1"),
1203
+ ("COPILOT_PROVIDER_API_KEY", "sk-byok"),
1204
+ ("COPILOT_PROVIDER_WIRE_API", "chat"),
1205
+ ("COPILOT_MODEL", "byok-model-z"),
1206
+ ] {
1207
+ if env.get(key).map(String::as_str) != Some(expected) {
1208
+ failures.push(format!(
1209
+ "A-4: BYOK worker env must carry {key}={expected}; got {:?}",
1210
+ env.get(key)
1211
+ ));
1212
+ }
1213
+ }
1214
+ if !env.contains_key("COPILOT_PROVIDER_TYPE") {
1215
+ failures.push("A-4: BYOK worker env must carry COPILOT_PROVIDER_TYPE".to_string());
1216
+ }
1217
+ }
1218
+ }
1219
+
1220
+ // BYOK profile WITHOUT a model → launch must be refused.
1221
+ let ws2 = tmp_ws("byok-nomodel");
1222
+ let team_dir2 = write_copilot_byok_team(&ws2, "cp-byok-nm", None);
1223
+ seed_healthy_coordinator(&ws2);
1224
+ match quick_start_with_transport_in_workspace(
1225
+ &ws2,
1226
+ &team_dir2,
1227
+ None,
1228
+ true,
1229
+ Some("cpbyoknm"),
1230
+ &RecordingTransport::new(),
1231
+ ) {
1232
+ Ok(_) => failures.push(
1233
+ "A-4: a BYOK profile without a model must fail (help-providers: 'A model is \
1234
+ required for BYOK'); got Ok"
1235
+ .to_string(),
1236
+ ),
1237
+ Err(error) => {
1238
+ if !error.to_string().to_lowercase().contains("model") {
1239
+ failures.push(format!(
1240
+ "A-4: the BYOK no-model error must name the missing model; got {error}"
1241
+ ));
1242
+ }
1243
+ }
1244
+ }
1245
+ assert!(
1246
+ failures.is_empty(),
1247
+ "A-4 BYOK contract failed:\n{}",
1248
+ failures.join("\n")
1249
+ );
1250
+ }
1251
+
1252
+ /// A-5 (design §E2.5): copilot has NO `auth status` subcommand (main-help Commands),
1253
+ /// so the subscription auth hint must be a WEAK/honest signal — it must NOT strongly
1254
+ /// claim "logged in" (AuthHintStatus::Present). A weak/unknown status is the honest
1255
+ /// answer (MUST-NOT-13: no false-green logged-in claim).
1256
+ #[test]
1257
+ fn copilot_subscription_auth_hint_is_weak_not_strong_present() {
1258
+ let status = team_agent::provider::get_adapter(Provider::Copilot)
1259
+ .auth_hint(team_agent::provider::AuthMode::Subscription);
1260
+ assert_ne!(
1261
+ format!("{status:?}"),
1262
+ "Present",
1263
+ "A-5: copilot has no `auth status` subcommand, so a strong Present is a false \
1264
+ green; the hint must be weak/unknown (honest — can't confirm logged-in); got {status:?}"
1265
+ );
1266
+ }
1267
+
1268
+ /// A-6 (design §E2.5): when the user shell carries GITHUB_TOKEN/GH_TOKEN (which copilot
1269
+ /// prioritizes over the credential store — cmd-login), spawning a copilot worker must
1270
+ /// emit a passthrough-warning event (phase-1 observe-only, no stripping).
1271
+ #[test]
1272
+ #[serial(env)]
1273
+ fn copilot_token_passthrough_emits_warning_event() {
1274
+ // Fake HOME so the residual MCP scan reads an empty copilot config (isolation:
1275
+ // otherwise it reads the real ~/.copilot and muddies this token-focused case).
1276
+ let home = tmp_ws("token-home");
1277
+ std::fs::create_dir_all(home.join(".copilot")).unwrap();
1278
+ let _guard = EnvGuard::set(&[
1279
+ (ANCESTRY_KEY, NEUTRAL_ANCESTRY),
1280
+ (
1281
+ "HOME",
1282
+ Box::leak(home.to_string_lossy().to_string().into_boxed_str()),
1283
+ ),
1284
+ ("GITHUB_TOKEN", "ghp-shell-passthrough"),
1285
+ ]);
1286
+ let ws = tmp_ws("token-warn");
1287
+ let team_dir = write_copilot_team(&ws, "cp-token", &["mcp_team"], false);
1288
+ seed_healthy_coordinator(&ws);
1289
+ let transport = RecordingTransport::new();
1290
+ quick_start_with_transport_in_workspace(
1291
+ &ws,
1292
+ &team_dir,
1293
+ None,
1294
+ true,
1295
+ Some("cptoken"),
1296
+ &transport,
1297
+ )
1298
+ .expect("quick-start with a shell token present");
1299
+
1300
+ let events = std::fs::read_to_string(ws.join(".team/logs/events.jsonl")).unwrap_or_default();
1301
+ assert!(
1302
+ events.contains("token")
1303
+ && (events.contains("passthrough") || events.contains("github_token")),
1304
+ "A-6: a shell GITHUB_TOKEN/GH_TOKEN (copilot prioritizes it over the credential \
1305
+ store) must emit a passthrough-warning event at spawn (phase-1 observe-only); events tail={}",
1306
+ events.lines().rev().take(4).collect::<Vec<_>>().join(" | ")
1307
+ );
1308
+ }
1309
+
1310
+ #[test]
1311
+ #[serial(env)]
1312
+ fn copilot_tmp_workspace_guard_cleans_and_sweeps_dead_pid_stale_roots() {
1313
+ if TempWorkspace::keep_enabled() {
1314
+ let _kept = tmp_ws("keep-escape");
1315
+ assert!(
1316
+ !copilot_tmp_entries().is_empty(),
1317
+ "TEAM_AGENT_KEEP_TEST_TMP=1 should preserve copilot tmp roots for debugging"
1318
+ );
1319
+ return;
1320
+ }
1321
+
1322
+ sweep_stale_copilot_tmp_roots();
1323
+ assert_eq!(
1324
+ copilot_tmp_entries().len(),
1325
+ 0,
1326
+ "test precondition: stale dead-pid ta-rs-copilot-* roots should be cleaned before assertion"
1327
+ );
1328
+
1329
+ let stale = std::env::temp_dir().join("ta-rs-copilot-stale-dead-999999999-0");
1330
+ let _ = std::fs::remove_dir_all(&stale);
1331
+ std::fs::create_dir_all(&stale).unwrap();
1332
+ {
1333
+ let guarded = tmp_ws("drop-check");
1334
+ assert!(
1335
+ !stale.exists(),
1336
+ "tmp_ws must sweep stale ta-rs-copilot-* roots before creating a new root"
1337
+ );
1338
+ assert!(
1339
+ guarded.exists(),
1340
+ "guarded copilot tmp root should exist while the guard is alive"
1341
+ );
1342
+ }
1343
+
1344
+ assert_eq!(
1345
+ copilot_tmp_entries().len(),
1346
+ 0,
1347
+ "TempWorkspace Drop must remove copilot tmp roots after normal test exit"
1348
+ );
1349
+ }
1350
+
1351
+ // ───────────────────────────────────── fixtures ─────────────────────────────────────
1352
+
1353
+ struct TeamOpts;
1354
+
1355
+ fn write_copilot_team_model(ws: &Path, name: &str, tools: &[&str], model: Option<&str>) -> PathBuf {
1356
+ let team = write_copilot_team(ws, name, tools, false);
1357
+ let agent_md = team.join("agents/worker_a.md");
1358
+ let stripped = std::fs::read_to_string(&agent_md)
1359
+ .unwrap()
1360
+ .lines()
1361
+ .filter(|line| !line.trim_start().starts_with("model:"))
1362
+ .map(|line| format!("{line}\n"))
1363
+ .collect::<String>();
1364
+ let body = match model {
1365
+ Some(model) => stripped.replace(
1366
+ "provider: copilot\n",
1367
+ &format!("provider: copilot\nmodel: {model}\n"),
1368
+ ),
1369
+ None => stripped,
1370
+ };
1371
+ std::fs::write(&agent_md, body).unwrap();
1372
+ team
1373
+ }
1374
+
1375
+ /// Compiled copilot team whose worker uses a compatible_api (BYOK) profile. When
1376
+ /// `model` is None the profile omits MODEL (the "A model is required for BYOK" case).
1377
+ fn write_copilot_byok_team(ws: &Path, name: &str, model: Option<&str>) -> PathBuf {
1378
+ let team = ws.join(name);
1379
+ std::fs::create_dir_all(team.join("agents")).unwrap();
1380
+ std::fs::create_dir_all(team.join("profiles")).unwrap();
1381
+ std::fs::write(
1382
+ team.join("TEAM.md"),
1383
+ format!("---\nname: {name}\nobjective: copilot BYOK fixture.\nprovider: copilot\n---\n\nTeam.\n"),
1384
+ )
1385
+ .unwrap();
1386
+ let model_field = model.map(|m| format!("model: {m}\n")).unwrap_or_default();
1387
+ std::fs::write(
1388
+ team.join("agents/worker_a.md"),
1389
+ format!(
1390
+ "---\nname: worker_a\nrole: Copilot Worker\nprovider: copilot\n{model_field}auth_mode: compatible_api\nprofile: byok\ntools:\n - mcp_team\n---\n\nCOPILOT ROLE BODY SENTINEL: BYOK worker.\n"
1391
+ ),
1392
+ )
1393
+ .unwrap();
1394
+ let model_line = model.map(|m| format!("MODEL={m}\n")).unwrap_or_default();
1395
+ std::fs::write(
1396
+ team.join("profiles/byok.env"),
1397
+ format!(
1398
+ "AUTH_MODE=compatible_api\nBASE_URL=https://byok.example/v1\nAPI_KEY=sk-byok\nWIRE_API=chat\nPROVIDER_TYPE=openai\n{model_line}"
1399
+ ),
1400
+ )
1401
+ .unwrap();
1402
+ let spec = team_agent::compiler::compile_team(&team).expect("compile BYOK fixture team");
1403
+ std::fs::write(
1404
+ team.join("team.spec.yaml"),
1405
+ team_agent::model::yaml::dumps(&spec),
1406
+ )
1407
+ .unwrap();
1408
+ team
1409
+ }
1410
+
1411
+ fn write_copilot_team(ws: &Path, name: &str, tools: &[&str], dangerous: bool) -> PathBuf {
1412
+ let _ = TeamOpts;
1413
+ let team = ws.join(name);
1414
+ std::fs::create_dir_all(team.join("agents")).unwrap();
1415
+ let dangerous_line = if dangerous {
1416
+ "dangerous_auto_approve: true\n"
1417
+ } else {
1418
+ ""
1419
+ };
1420
+ std::fs::write(
1421
+ team.join("TEAM.md"),
1422
+ format!(
1423
+ "---\nname: {name}\nobjective: copilot provider contract fixture.\nprovider: copilot\n{dangerous_line}---\n\nTeam fixture.\n"
1424
+ ),
1425
+ )
1426
+ .unwrap();
1427
+ let tool_lines = tools
1428
+ .iter()
1429
+ .map(|tool| format!(" - {tool}\n"))
1430
+ .collect::<String>();
1431
+ std::fs::write(
1432
+ team.join("agents/worker_a.md"),
1433
+ format!(
1434
+ "---\nname: worker_a\nrole: Copilot Worker\nprovider: copilot\nmodel: gpt-5\nauth_mode: subscription\ntools:\n{tool_lines}---\n\nCOPILOT ROLE BODY SENTINEL: serve the copilot adapter contract.\n"
1435
+ ),
1436
+ )
1437
+ .unwrap();
1438
+ team
1439
+ }
1440
+
1441
+ fn seed_running_resumable(ws: &Path, agent: &str) {
1442
+ let mut state = load_runtime_state(ws).expect("load state before restart");
1443
+ let patch = serde_json::json!({
1444
+ "status": "running",
1445
+ "provider": "copilot",
1446
+ "session_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd",
1447
+ "first_send_at": "2026-06-10T00:00:00+00:00",
1448
+ "window": agent,
1449
+ });
1450
+ for pointer in [
1451
+ format!("/agents/{agent}"),
1452
+ format!("/teams/cpresume/agents/{agent}"),
1453
+ ] {
1454
+ if let Some(obj) = state
1455
+ .pointer_mut(&pointer)
1456
+ .and_then(serde_json::Value::as_object_mut)
1457
+ {
1458
+ for (key, value) in patch.as_object().unwrap() {
1459
+ obj.insert(key.clone(), value.clone());
1460
+ }
1461
+ }
1462
+ }
1463
+ team_agent::state::persist::save_runtime_state(ws, &state).unwrap();
1464
+ }
1465
+
1466
+ fn seed_copilot_session_store(home: &Path, session_id: &str) {
1467
+ let db = home.join(".copilot").join("session-store.db");
1468
+ std::fs::create_dir_all(db.parent().unwrap()).unwrap();
1469
+ let conn = rusqlite::Connection::open(db).unwrap();
1470
+ conn.execute_batch(
1471
+ "create table if not exists sessions (
1472
+ id text primary key,
1473
+ name text,
1474
+ created_at text
1475
+ );",
1476
+ )
1477
+ .unwrap();
1478
+ conn.execute(
1479
+ "insert or replace into sessions(id, name, created_at) values (?1, 'resume-test', '2026-06-10T00:00:00Z')",
1480
+ rusqlite::params![session_id],
1481
+ )
1482
+ .unwrap();
1483
+ }
1484
+
1485
+ fn seed_session_id(ws: &Path, agent: &str) {
1486
+ // 0.4.6 tuple-atomic contract: fork now requires a complete source
1487
+ // tuple (session_id + rollout_path + captured_at + captured_via).
1488
+ // Tests that exercise downstream fork behaviour (capability gate,
1489
+ // adapter error path) need the source row to PASS the backing check
1490
+ // so the test reaches the behaviour it actually asserts. Seed the
1491
+ // full tuple with a real rollout file so the guard is satisfied.
1492
+ let rollout = ws.join(format!("{agent}-rollout.jsonl"));
1493
+ if !rollout.exists() {
1494
+ std::fs::write(&rollout, "{}\n").expect("seed rollout");
1495
+ }
1496
+ let mut state = load_runtime_state(ws).unwrap();
1497
+ for pointer in [
1498
+ format!("/agents/{agent}"),
1499
+ format!("/teams/cpfork/agents/{agent}"),
1500
+ ] {
1501
+ if let Some(obj) = state
1502
+ .pointer_mut(&pointer)
1503
+ .and_then(serde_json::Value::as_object_mut)
1504
+ {
1505
+ obj.insert(
1506
+ "session_id".to_string(),
1507
+ serde_json::json!("77777777-8888-4999-aaaa-bbbbbbbbbbbb"),
1508
+ );
1509
+ obj.insert(
1510
+ "rollout_path".to_string(),
1511
+ serde_json::json!(rollout.to_string_lossy()),
1512
+ );
1513
+ obj.insert(
1514
+ "captured_at".to_string(),
1515
+ serde_json::json!("2026-06-25T10:00:00+00:00"),
1516
+ );
1517
+ obj.insert(
1518
+ "captured_via".to_string(),
1519
+ serde_json::json!("session.captured"),
1520
+ );
1521
+ }
1522
+ }
1523
+ team_agent::state::persist::save_runtime_state(ws, &state).unwrap();
1524
+ }
1525
+
1526
+ fn seed_healthy_coordinator(workspace: &Path) {
1527
+ let workspace = team_agent::coordinator::WorkspacePath::new(workspace.to_path_buf());
1528
+ let pid = team_agent::coordinator::Pid::new(std::process::id());
1529
+ std::fs::create_dir_all(
1530
+ team_agent::coordinator::coordinator_pid_path(&workspace)
1531
+ .parent()
1532
+ .unwrap(),
1533
+ )
1534
+ .unwrap();
1535
+ team_agent::coordinator::write_coordinator_metadata(
1536
+ &workspace,
1537
+ pid,
1538
+ team_agent::coordinator::MetadataSource::Boot,
1539
+ )
1540
+ .expect("write coordinator metadata");
1541
+ std::fs::write(
1542
+ team_agent::coordinator::coordinator_pid_path(&workspace),
1543
+ pid.to_string(),
1544
+ )
1545
+ .expect("write coordinator pid");
1546
+ }
1547
+
1548
+ fn flag_value(argv: &[String], flag: &str) -> Option<String> {
1549
+ argv.windows(2)
1550
+ .find_map(|pair| (pair[0] == flag).then(|| pair[1].clone()))
1551
+ }
1552
+
1553
+ fn flag_values(argv: &[String], flag: &str) -> Vec<String> {
1554
+ argv.windows(2)
1555
+ .filter_map(|pair| (pair[0] == flag).then(|| pair[1].clone()))
1556
+ .collect()
1557
+ }
1558
+
1559
+ struct EnvGuard {
1560
+ previous: Vec<(&'static str, Option<String>)>,
1561
+ _owned_paths: Vec<TempWorkspace>,
1562
+ }
1563
+
1564
+ impl EnvGuard {
1565
+ fn set(values: &[(&'static str, &'static str)]) -> Self {
1566
+ let mut owned_paths = Vec::new();
1567
+ let mut resolved = values
1568
+ .iter()
1569
+ .map(|(key, value)| (*key, (*value).to_string()))
1570
+ .collect::<Vec<_>>();
1571
+ if !values.iter().any(|(key, _)| *key == "PATH") {
1572
+ let shim = copilot_empty_mcp_list_shim("path-shim");
1573
+ resolved.push(("PATH", shim.path().to_string_lossy().into_owned()));
1574
+ owned_paths.push(shim);
1575
+ }
1576
+ Self::set_owned(resolved, owned_paths)
1577
+ }
1578
+
1579
+ fn set_owned(values: Vec<(&'static str, String)>, owned_paths: Vec<TempWorkspace>) -> Self {
1580
+ let previous = values
1581
+ .iter()
1582
+ .map(|(key, _)| (*key, std::env::var(key).ok()))
1583
+ .collect::<Vec<_>>();
1584
+ for (key, value) in &values {
1585
+ std::env::set_var(key, value);
1586
+ }
1587
+ Self {
1588
+ previous,
1589
+ _owned_paths: owned_paths,
1590
+ }
1591
+ }
1592
+ }
1593
+
1594
+ impl Drop for EnvGuard {
1595
+ fn drop(&mut self) {
1596
+ for (key, value) in self.previous.drain(..).rev() {
1597
+ match value {
1598
+ Some(value) => std::env::set_var(key, value),
1599
+ None => std::env::remove_var(key),
1600
+ }
1601
+ }
1602
+ }
1603
+ }
1604
+
1605
+ #[derive(Debug, Clone)]
1606
+ struct RecordedSpawn {
1607
+ argv: Vec<String>,
1608
+ env: BTreeMap<String, String>,
1609
+ window: String,
1610
+ }
1611
+
1612
+ #[derive(Default)]
1613
+ struct RecordingTransport {
1614
+ spawns: Mutex<Vec<RecordedSpawn>>,
1615
+ session_present: Mutex<bool>,
1616
+ }
1617
+
1618
+ impl RecordingTransport {
1619
+ fn new() -> Self {
1620
+ Self::default()
1621
+ }
1622
+
1623
+ fn with_session_present() -> Self {
1624
+ let transport = Self::default();
1625
+ *transport.session_present.lock().unwrap() = true;
1626
+ transport
1627
+ }
1628
+
1629
+ fn single_spawn(&self) -> RecordedSpawn {
1630
+ let spawns = self.spawns.lock().unwrap();
1631
+ assert_eq!(
1632
+ spawns.len(),
1633
+ 1,
1634
+ "fixture should record one spawn; got {spawns:?}"
1635
+ );
1636
+ spawns[0].clone()
1637
+ }
1638
+ }
1639
+
1640
+ impl Transport for RecordingTransport {
1641
+ fn kind(&self) -> BackendKind {
1642
+ BackendKind::Tmux
1643
+ }
1644
+
1645
+ fn spawn_first(
1646
+ &self,
1647
+ session: &SessionName,
1648
+ window: &WindowName,
1649
+ argv: &[String],
1650
+ _cwd: &Path,
1651
+ env: &BTreeMap<String, String>,
1652
+ ) -> Result<SpawnResult, TransportError> {
1653
+ let mut spawns = self.spawns.lock().unwrap();
1654
+ spawns.push(RecordedSpawn {
1655
+ argv: argv.to_vec(),
1656
+ env: env.clone(),
1657
+ window: window.as_str().to_string(),
1658
+ });
1659
+ *self.session_present.lock().unwrap() = true;
1660
+ Ok(SpawnResult {
1661
+ pane_id: PaneId::new(format!("%{}", spawns.len())),
1662
+ session: session.clone(),
1663
+ window: window.clone(),
1664
+ child_pid: Some(30_000 + spawns.len() as u32),
1665
+ })
1666
+ }
1667
+
1668
+ fn spawn_into(
1669
+ &self,
1670
+ session: &SessionName,
1671
+ window: &WindowName,
1672
+ argv: &[String],
1673
+ cwd: &Path,
1674
+ env: &BTreeMap<String, String>,
1675
+ ) -> Result<SpawnResult, TransportError> {
1676
+ self.spawn_first(session, window, argv, cwd, env)
1677
+ }
1678
+
1679
+ fn inject(
1680
+ &self,
1681
+ _target: &Target,
1682
+ _payload: &InjectPayload,
1683
+ _submit: Key,
1684
+ _bracketed: bool,
1685
+ ) -> Result<InjectReport, TransportError> {
1686
+ Ok(InjectReport {
1687
+ stage_reached: InjectStage::Submit,
1688
+ inject_verification: InjectVerification::CaptureContainsToken,
1689
+ submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
1690
+ turn_verification: TurnVerification::NotYetObserved,
1691
+ attempts: 1,
1692
+ submit_diagnostics: None,
1693
+ })
1694
+ }
1695
+
1696
+ fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
1697
+ Ok(())
1698
+ }
1699
+
1700
+ fn capture(
1701
+ &self,
1702
+ _target: &Target,
1703
+ range: CaptureRange,
1704
+ ) -> Result<CapturedText, TransportError> {
1705
+ Ok(CapturedText {
1706
+ text: "Claude Code\n> \nOpenAI Codex\ncodex>\nCopilot".to_string(),
1707
+ range,
1708
+ })
1709
+ }
1710
+
1711
+ fn query(&self, _target: &Target, field: PaneField) -> Result<Option<String>, TransportError> {
1712
+ match field {
1713
+ PaneField::PaneWidth => Ok(Some("120".to_string())),
1714
+ _ => Ok(None),
1715
+ }
1716
+ }
1717
+
1718
+ fn liveness(
1719
+ &self,
1720
+ _pane: &PaneId,
1721
+ ) -> Result<team_agent::transport::PaneLiveness, TransportError> {
1722
+ Ok(team_agent::transport::PaneLiveness::Live)
1723
+ }
1724
+
1725
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
1726
+ Ok(Vec::new())
1727
+ }
1728
+
1729
+ fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
1730
+ Ok(*self.session_present.lock().unwrap())
1731
+ }
1732
+
1733
+ fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
1734
+ Ok(Vec::new())
1735
+ }
1736
+
1737
+ fn set_session_env(
1738
+ &self,
1739
+ _session: &SessionName,
1740
+ _key: &str,
1741
+ _value: &str,
1742
+ ) -> Result<SetEnvOutcome, TransportError> {
1743
+ Ok(SetEnvOutcome::Applied)
1744
+ }
1745
+
1746
+ fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
1747
+ *self.session_present.lock().unwrap() = false;
1748
+ Ok(())
1749
+ }
1750
+
1751
+ fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
1752
+ Ok(())
1753
+ }
1754
+
1755
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
1756
+ Ok(AttachOutcome::Attached)
1757
+ }
1758
+ }
1759
+
1760
+ fn tmp_ws(tag: &str) -> TempWorkspace {
1761
+ sweep_stale_copilot_tmp_roots();
1762
+ TempWorkspace::new(COPILOT_TMP_PREFIX, tag)
1763
+ }
1764
+
1765
+ fn copilot_empty_mcp_list_shim(tag: &str) -> TempWorkspace {
1766
+ let bin = tmp_ws(tag);
1767
+ std::fs::write(
1768
+ bin.join("copilot"),
1769
+ "#!/bin/sh\nif [ \"$1\" = \"mcp\" ] && [ \"$2\" = \"list\" ]; then\n exit 0\nfi\nexit 0\n",
1770
+ )
1771
+ .unwrap();
1772
+ #[cfg(unix)]
1773
+ {
1774
+ use std::os::unix::fs::PermissionsExt;
1775
+ std::fs::set_permissions(bin.join("copilot"), std::fs::Permissions::from_mode(0o755))
1776
+ .unwrap();
1777
+ }
1778
+ bin
1779
+ }
1780
+
1781
+ fn copilot_tmp_search_roots() -> Vec<PathBuf> {
1782
+ let mut seen = BTreeSet::new();
1783
+ let mut roots = Vec::new();
1784
+ for root in [std::env::temp_dir(), PathBuf::from("/private/tmp")] {
1785
+ if !root.is_dir() {
1786
+ continue;
1787
+ }
1788
+ let canonical = std::fs::canonicalize(&root).unwrap_or(root);
1789
+ if seen.insert(canonical.clone()) {
1790
+ roots.push(canonical);
1791
+ }
1792
+ }
1793
+ roots
1794
+ }
1795
+
1796
+ fn copilot_tmp_entries() -> Vec<PathBuf> {
1797
+ let mut out = Vec::new();
1798
+ for root in copilot_tmp_search_roots() {
1799
+ let Ok(entries) = std::fs::read_dir(root) else {
1800
+ continue;
1801
+ };
1802
+ for entry in entries.flatten() {
1803
+ let path = entry.path();
1804
+ let Ok(file_type) = entry.file_type() else {
1805
+ continue;
1806
+ };
1807
+ if !file_type.is_dir() {
1808
+ continue;
1809
+ }
1810
+ let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1811
+ continue;
1812
+ };
1813
+ if name.starts_with("ta-rs-copilot-") {
1814
+ out.push(path);
1815
+ }
1816
+ }
1817
+ }
1818
+ out
1819
+ }
1820
+
1821
+ fn sweep_stale_copilot_tmp_roots() {
1822
+ for path in copilot_tmp_entries() {
1823
+ if copilot_tmp_path_has_dead_owner_pid(&path) {
1824
+ let _ = std::fs::remove_dir_all(path);
1825
+ }
1826
+ }
1827
+ }
1828
+
1829
+ fn copilot_tmp_path_has_dead_owner_pid(path: &Path) -> bool {
1830
+ let Some(pid) = path
1831
+ .file_name()
1832
+ .and_then(|name| name.to_str())
1833
+ .and_then(copilot_tmp_pid_from_name)
1834
+ else {
1835
+ return false;
1836
+ };
1837
+ pid != std::process::id() && !pid_is_live(pid)
1838
+ }
1839
+
1840
+ fn copilot_tmp_pid_from_name(name: &str) -> Option<u32> {
1841
+ if !name.starts_with("ta-rs-copilot-") {
1842
+ return None;
1843
+ }
1844
+ name.rsplit('-').nth(1)?.parse().ok()
1845
+ }
1846
+
1847
+ fn pid_is_live(pid: u32) -> bool {
1848
+ Command::new("kill")
1849
+ .arg("-0")
1850
+ .arg(pid.to_string())
1851
+ .stdout(Stdio::null())
1852
+ .stderr(Stdio::null())
1853
+ .status()
1854
+ .map(|status| status.success())
1855
+ .unwrap_or(false)
1856
+ }