@team-agent/installer 0.5.1 → 0.5.2

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 (37) hide show
  1. package/Cargo.lock +118 -9
  2. package/Cargo.toml +2 -2
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/app_server_test_support.rs +258 -0
  5. package/crates/team-agent/src/cli/adapters.rs +32 -3
  6. package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
  7. package/crates/team-agent/src/cli/diagnose.rs +14 -5
  8. package/crates/team-agent/src/cli/emit.rs +74 -1
  9. package/crates/team-agent/src/cli/mod.rs +70 -25
  10. package/crates/team-agent/src/cli/named_address.rs +111 -0
  11. package/crates/team-agent/src/cli/send.rs +98 -3
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/named_address.rs +131 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  15. package/crates/team-agent/src/cli/types.rs +17 -0
  16. package/crates/team-agent/src/codex_app_server.rs +488 -0
  17. package/crates/team-agent/src/conpty/backend.rs +710 -0
  18. package/crates/team-agent/src/conpty/mod.rs +32 -0
  19. package/crates/team-agent/src/coordinator/backoff.rs +45 -6
  20. package/crates/team-agent/src/diagnose/orphans.rs +11 -3
  21. package/crates/team-agent/src/leader/lease.rs +144 -7
  22. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  23. package/crates/team-agent/src/leader/start.rs +18 -5
  24. package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
  25. package/crates/team-agent/src/lib.rs +23 -0
  26. package/crates/team-agent/src/lifecycle/launch.rs +127 -3
  27. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  28. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  29. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +63 -0
  30. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  31. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  32. package/crates/team-agent/src/messaging/mod.rs +2 -2
  33. package/crates/team-agent/src/messaging/results.rs +78 -21
  34. package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
  35. package/crates/team-agent/src/tmux_backend.rs +31 -0
  36. package/crates/team-agent/src/transport_factory.rs +632 -0
  37. package/package.json +4 -4
@@ -0,0 +1,32 @@
1
+ //! 0.5.x Windows-native transport Phase 1: ConPTY backend.
2
+ //!
3
+ //! Wire protocol + portable shim state machine live in the
4
+ //! `conpty-transport` crate (portable, no team-agent runtime deps —
5
+ //! consumed directly by the Windows `windows-shim` binary). This
6
+ //! module wires that portable machinery into team-agent's `Transport`
7
+ //! trait via `ConPtyBackend`.
8
+ //!
9
+ //! Truth sources (READ-ONLY):
10
+ //! - Design: `.team/artifacts/0.5.x-windows-native-transport-design.md`
11
+ //! - CR verdict: `.team/artifacts/0.5.x-windows-transport-cr-verdict.md`
12
+ //!
13
+ //! CR anchors:
14
+ //! - **C-1 pipe_token**: only lives in `ConPtyBackend.pipe_token`
15
+ //! (Mutex) and the shim's in-memory state; never persisted. Grep
16
+ //! guard test (`conpty_pipe_token_no_persist_guard.rs`) locks this.
17
+ //! - **C-2 kill_server**: forwards to `Op::Shutdown` per-team, not
18
+ //! global. See CR pre-check in the gate report.
19
+ //! - **C-3 stale event**: emitted by the backend when `state.shim_pid`
20
+ //! disagrees with the shim's `hello` response.
21
+ //! - **C-5 pipe_token_mismatch**: `ProtocolError::PipeTokenMismatch`
22
+ //! maps to `TransportError::MuxUnavailable`, NEVER silent retry.
23
+
24
+ pub mod backend;
25
+
26
+ pub use backend::{ConPtyBackend, PipeClientAdapter, PipeClientTrait};
27
+ // Re-export the portable protocol + shim types from the standalone
28
+ // crate so downstream code can `use team_agent::conpty::{Shim,
29
+ // FakePaneRuntime, ...}` without a second dependency.
30
+ pub use conpty_transport::{
31
+ protocol, shim, FakePaneRuntime, LocalShimClient, PaneRuntime, PipeClient, Shim,
32
+ };
@@ -36,19 +36,58 @@ pub fn run_daemon(args: DaemonArgs) -> Result<(), DaemonError> {
36
36
  // CP-1: the daemon's whole tick surface (has_session / capture / inject / list_windows / kill)
37
37
  // runs through this backend. Prefer the persisted runtime endpoint so attached explicit-socket
38
38
  // teams are checked on the same socket as lifecycle worker operations.
39
+ //
40
+ // 0.5.x Phase 1d Batch 3: coordinator boot now routes through
41
+ // `transport_factory::resolve_transport` so `state.transport.kind =
42
+ // conpty` boots the ConPTY backend, not a tmux backend that would
43
+ // fake `has_session()`. Tmux behavior is byte-equivalent (Layer 3
44
+ // legacy `tmux_endpoint` → same `tmux_backend_for_runtime_state_or_workspace`
45
+ // shape inside the factory's `build_tmux`).
39
46
  let state = crate::state::persist::load_runtime_state(args.workspace.as_path()).ok();
40
- let tmux_selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
47
+ let factory_input = crate::transport_factory::TransportFactoryInput::new(
41
48
  args.workspace.as_path(),
42
- state.as_ref(),
43
- );
49
+ crate::transport_factory::TransportPurpose::Coordinator,
50
+ )
51
+ .with_state(state.as_ref());
52
+ let resolved = match crate::transport_factory::resolve_transport(factory_input) {
53
+ Ok(resolved) => resolved,
54
+ Err(e) => {
55
+ // Fail-closed honest degradation: coordinator boots with a
56
+ // tmux-workspace backend so it doesn't crash, but records
57
+ // the assembly refusal in the boot metadata source so the
58
+ // event log shows the reason. This preserves the daemon
59
+ // liveness path while making the failure explicit.
60
+ eprintln!(
61
+ "coordinator: transport_factory refused ({e}); falling back to tmux workspace for daemon liveness"
62
+ );
63
+ let sel = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
64
+ args.workspace.as_path(),
65
+ state.as_ref(),
66
+ );
67
+ let metadata = DaemonTmuxEndpointMetadata {
68
+ tmux_endpoint_used: sel.tmux_endpoint_used.clone(),
69
+ tmux_endpoint_source: "factory_refused_fallback",
70
+ };
71
+ let coord = Coordinator::new(
72
+ args.workspace.clone(),
73
+ Box::new(RealProviderRegistry),
74
+ Box::new(sel.backend),
75
+ );
76
+ return run_daemon_with_coordinator_and_boot_tmux(&args, &coord, Some(metadata));
77
+ }
78
+ };
79
+ // Preserve tmux boot metadata byte-equivalent for tmux teams.
80
+ // ConPTY teams get their source string but no tmux endpoint (design
81
+ // §Behavior Equivalence: same `tmux_endpoint_used` for tmux; None
82
+ // + honest source for conpty).
44
83
  let tmux_metadata = DaemonTmuxEndpointMetadata {
45
- tmux_endpoint_used: tmux_selection.tmux_endpoint_used.clone(),
46
- tmux_endpoint_source: tmux_selection.tmux_endpoint_source.as_str(),
84
+ tmux_endpoint_used: resolved.tmux_endpoint_used.clone(),
85
+ tmux_endpoint_source: resolved.source,
47
86
  };
48
87
  let coordinator = Coordinator::new(
49
88
  args.workspace.clone(),
50
89
  Box::new(RealProviderRegistry),
51
- Box::new(tmux_selection.backend),
90
+ resolved.backend,
52
91
  );
53
92
  run_daemon_with_coordinator_and_boot_tmux(&args, &coordinator, Some(tmux_metadata))
54
93
  }
@@ -10,7 +10,8 @@ use crate::coordinator::health::{
10
10
  coordinator_metadata_ok, pid_is_running, read_coordinator_metadata, terminate_pid_tree,
11
11
  };
12
12
  use crate::coordinator::types::{OrphanReason, Pid, WorkspacePath};
13
- use crate::tmux_backend::TmuxBackend;
13
+ // Phase 1d Batch 6: `TmuxBackend` import removed — all sites now use
14
+ // `transport_factory::tmux_socket_name_transport` for grep-visibility.
14
15
  use crate::transport::{SessionName, Transport};
15
16
 
16
17
  #[derive(Debug, Clone)]
@@ -352,7 +353,11 @@ fn tmux_socket_roots() -> Vec<PathBuf> {
352
353
  }
353
354
 
354
355
  fn tmux_list_panes(socket: &str) -> Vec<TmuxPaneRow> {
355
- TmuxBackend::for_socket_name(socket)
356
+ // Phase 1d Batch 6: factory tmux-socket-name helper for
357
+ // grep-visibility. Tmux-only orphan scanner (enumerates tmux socket
358
+ // roots); ConPTY orphan diagnostics are a separate shim registry
359
+ // command, not routed here.
360
+ crate::transport_factory::tmux_socket_name_transport(socket)
356
361
  .list_targets()
357
362
  .unwrap_or_default()
358
363
  .into_iter()
@@ -664,7 +669,10 @@ fn kill_tmux_session(orphan: &OrphanRecord) -> bool {
664
669
  let (Some(socket), Some(session)) = (&orphan.tmux_socket, &orphan.session) else {
665
670
  return false;
666
671
  };
667
- TmuxBackend::for_socket_name(socket)
672
+ // Phase 1d Batch 6: factory tmux-socket-name helper. Explicit
673
+ // tmux-only kill; conpty shim orphans are NOT routed here (design
674
+ // §Batch 6).
675
+ crate::transport_factory::tmux_socket_name_transport(socket)
668
676
  .kill_session(&SessionName::new(session.clone()))
669
677
  .is_ok()
670
678
  }
@@ -134,6 +134,58 @@ pub fn attach_leader(
134
134
  })
135
135
  }
136
136
 
137
+ /// Explicit app-server leader binding. Validates the supplied socket/thread tuple
138
+ /// before writing the typed physical-channel anchor through the lease primitive.
139
+ pub fn attach_app_server_leader(
140
+ workspace: &Path,
141
+ team: Option<&str>,
142
+ socket: &str,
143
+ thread_id: &str,
144
+ ) -> Result<Value, LeaderError> {
145
+ let binding = crate::codex_app_server::attach_probe(socket, thread_id)
146
+ .map_err(|error| LeaderError::Validation(error.to_string()))?;
147
+ let event_log = crate::event_log::EventLog::new(workspace);
148
+ let scoped_team = team.filter(|value| !value.is_empty());
149
+ let mut state = if scoped_team.is_some() {
150
+ crate::state::projection::select_runtime_state(workspace, scoped_team)?
151
+ } else {
152
+ crate::state::persist::load_runtime_state(workspace)?
153
+ };
154
+ if !state.is_object() {
155
+ state = json!({});
156
+ }
157
+ if let Some(team) = scoped_team {
158
+ state["active_team_key"] = json!(team);
159
+ }
160
+ let team_key = canonical_owner_write_key(&state);
161
+ let next_epoch = OwnerEpoch(current_owner_epoch(&state).0.saturating_add(1));
162
+ let receiver = app_server_receiver_value(&binding, next_epoch);
163
+ let owner = app_server_owner_value(next_epoch);
164
+ let record = crate::state::ownership::OwnershipWrite::new()
165
+ .with_leader_receiver(receiver.clone())
166
+ .with_team_owner(owner.clone())
167
+ .with_owner_epoch(next_epoch.0);
168
+ crate::state::ownership::write_owner(&mut state, &team_key, record);
169
+ write_claim_state(workspace, &state, scoped_team, Some(&team_key))?;
170
+ event_log.write(
171
+ super::LeaderEvent::ReceiverAttached.name(),
172
+ json!({
173
+ "transport_kind": "codex_app_server",
174
+ "thread_id": binding.thread_id,
175
+ "owner_epoch": next_epoch.0,
176
+ "team": team_key,
177
+ }),
178
+ )?;
179
+ Ok(json!({
180
+ "ok": true,
181
+ "status": "claimed",
182
+ "team": team_key,
183
+ "owner_epoch": next_epoch.0,
184
+ "leader_receiver": receiver,
185
+ "team_owner": owner,
186
+ }))
187
+ }
188
+
137
189
  #[derive(Clone)]
138
190
  struct AttachLeaderTarget {
139
191
  info: PaneInfo,
@@ -156,8 +208,11 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
156
208
  }),
157
209
  );
158
210
  }
211
+ // Phase 1d Batch 6: factory tmux workspace helper for
212
+ // grep-visibility. Semantics unchanged; leader lease discovery is
213
+ // tmux-only (caller pane = tmux pane, MUST-12 anchor).
159
214
  targets.extend(
160
- crate::tmux_backend::TmuxBackend::for_workspace(workspace)
215
+ crate::transport_factory::tmux_workspace_transport(workspace)
161
216
  .list_targets()
162
217
  .unwrap_or_default()
163
218
  .into_iter()
@@ -167,10 +222,12 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
167
222
  }
168
223
 
169
224
  fn tmux_backend_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
225
+ // Phase 1d Batch 6: factory tmux channel helpers for grep-visibility.
226
+ // Same tmux-only semantics as before.
170
227
  if endpoint.is_empty() || endpoint == "default" {
171
- crate::tmux_backend::TmuxBackend::new()
228
+ crate::transport_factory::tmux_default_transport()
172
229
  } else {
173
- crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
230
+ crate::transport_factory::tmux_endpoint_transport(endpoint)
174
231
  }
175
232
  }
176
233
 
@@ -320,11 +377,13 @@ pub fn claim_leader(
320
377
  .unwrap_or_default();
321
378
  let raw_state = crate::state::persist::load_runtime_state(workspace)?;
322
379
  let event_log = crate::event_log::EventLog::new(workspace);
323
- let mut targets = crate::tmux_backend::TmuxBackend::for_workspace(workspace)
380
+ // Phase 1d Batch 6: factory tmux channel helpers for grep-visibility.
381
+ // Tmux-only leader-claim path (caller pane enumeration).
382
+ let mut targets = crate::transport_factory::tmux_workspace_transport(workspace)
324
383
  .list_targets()
325
384
  .unwrap_or_default();
326
385
  targets.extend(
327
- crate::tmux_backend::TmuxBackend::new()
386
+ crate::transport_factory::tmux_default_transport()
328
387
  .list_targets()
329
388
  .unwrap_or_default(),
330
389
  );
@@ -1055,7 +1114,7 @@ fn write_binding_to_state(
1055
1114
  }
1056
1115
  let team_key = canonical_owner_write_key(state);
1057
1116
  let record = crate::state::ownership::OwnershipWrite::new()
1058
- .with_leader_receiver(serde_json::to_value(receiver)?)
1117
+ .with_leader_receiver(tmux_receiver_value(receiver)?)
1059
1118
  .with_team_owner(serde_json::to_value(owner)?)
1060
1119
  .with_owner_epoch(owner.owner_epoch.0);
1061
1120
  crate::state::ownership::write_owner(state, &team_key, record);
@@ -1074,11 +1133,89 @@ fn write_receiver_to_state(
1074
1133
  }
1075
1134
  let team_key = canonical_owner_write_key(state);
1076
1135
  let record = crate::state::ownership::OwnershipWrite::new()
1077
- .with_leader_receiver(serde_json::to_value(receiver)?);
1136
+ .with_leader_receiver(tmux_receiver_value(receiver)?);
1078
1137
  crate::state::ownership::write_owner(state, &team_key, record);
1079
1138
  Ok(())
1080
1139
  }
1081
1140
 
1141
+ fn tmux_receiver_value(receiver: &LeaderReceiver) -> Result<Value, LeaderError> {
1142
+ Ok(write_leader_receiver_transport(
1143
+ serde_json::to_value(receiver)?,
1144
+ "direct_tmux",
1145
+ ))
1146
+ }
1147
+
1148
+ fn app_server_receiver_value(
1149
+ binding: &crate::codex_app_server::AppServerBinding,
1150
+ epoch: OwnerEpoch,
1151
+ ) -> Value {
1152
+ write_leader_receiver_transport(
1153
+ json!({
1154
+ "status": "attached",
1155
+ "provider": "codex",
1156
+ "owner_epoch": epoch.0,
1157
+ "app_server": {
1158
+ "socket": binding.socket,
1159
+ "thread_id": binding.thread_id,
1160
+ "session_id": binding.session_id,
1161
+ "cwd": binding.cwd,
1162
+ "cli_version": binding.cli_version,
1163
+ "bound_at": binding.bound_at,
1164
+ "source": "app-server"
1165
+ }
1166
+ }),
1167
+ "codex_app_server",
1168
+ )
1169
+ }
1170
+
1171
+ fn app_server_owner_value(epoch: OwnerEpoch) -> Value {
1172
+ json!({
1173
+ "provider": "codex",
1174
+ "transport_kind": "codex_app_server",
1175
+ "owner_epoch": epoch.0,
1176
+ "claimed_at": now_ts(),
1177
+ "claimed_via": "attach-app-server-leader",
1178
+ "os_user": std::env::var("USER")
1179
+ .or_else(|_| std::env::var("USERNAME"))
1180
+ .unwrap_or_default(),
1181
+ })
1182
+ }
1183
+
1184
+ fn write_leader_receiver_transport(mut receiver: Value, transport_kind: &str) -> Value {
1185
+ if let Some(obj) = receiver.as_object_mut() {
1186
+ obj.insert("mode".to_string(), json!(transport_kind));
1187
+ match transport_kind {
1188
+ "codex_app_server" => {
1189
+ obj.insert("transport_kind".to_string(), json!(transport_kind));
1190
+ for legacy in [
1191
+ "pane_id",
1192
+ "tmux_socket",
1193
+ "session_name",
1194
+ "window_index",
1195
+ "window_name",
1196
+ "pane_index",
1197
+ "pane_tty",
1198
+ "pane_current_command",
1199
+ "fingerprint",
1200
+ "leader_session_uuid",
1201
+ "attached_at",
1202
+ "discovery",
1203
+ "requested_provider",
1204
+ "warning",
1205
+ ] {
1206
+ obj.remove(legacy);
1207
+ }
1208
+ }
1209
+ "direct_tmux" => {
1210
+ obj.remove("app_server");
1211
+ obj.remove("transport_kind");
1212
+ }
1213
+ _ => {}
1214
+ }
1215
+ }
1216
+ receiver
1217
+ }
1218
+
1082
1219
  /// Stage 3 owner persist fix (architect direction 2026-06-24): determine
1083
1220
  /// the canonical team_key for an in-memory ownership write. When the state
1084
1221
  /// carries an `active_team_key` (set by `project_top_level_view` whenever
@@ -262,7 +262,9 @@ fn family_a_identity(
262
262
  }
263
263
 
264
264
  fn tmux_pane_current_command(workspace: &Path, pane: &str) -> Result<String, LeaderError> {
265
- TmuxBackend::for_workspace(workspace)
265
+ // Phase 1d Batch 6: factory tmux workspace helper for grep-visibility.
266
+ // Tmux-only owner-bind (caller pane = tmux pane).
267
+ crate::transport_factory::tmux_workspace_transport(workspace)
266
268
  .query(
267
269
  &Target::Pane(PaneId::new(pane)),
268
270
  PaneField::PaneCurrentCommand,
@@ -273,7 +275,8 @@ fn tmux_pane_current_command(workspace: &Path, pane: &str) -> Result<String, Lea
273
275
 
274
276
  fn tmux_pane_info(workspace: &Path, pane: &str) -> Option<PaneInfo> {
275
277
  let target = PaneId::new(pane);
276
- TmuxBackend::for_workspace(workspace)
278
+ // Phase 1d Batch 6: same as above.
279
+ crate::transport_factory::tmux_workspace_transport(workspace)
277
280
  .list_targets()
278
281
  .ok()?
279
282
  .into_iter()
@@ -434,7 +434,12 @@ fn execute_managed_leader_plan(
434
434
  let Some(window) = plan.leader_window.as_ref() else {
435
435
  return Err(LeaderError::Start("managed leader window missing".to_string()));
436
436
  };
437
- let transport = TmuxBackend::for_workspace(workspace);
437
+ // 0.5.x Phase 1d Batch 6: use the factory tmux channel helper
438
+ // (thin wrapper over `TmuxBackend::for_workspace`) for
439
+ // grep-visibility of every intentional tmux-only leader-launcher
440
+ // site. Semantics unchanged; managed leader hosting stays
441
+ // tmux-only per design §Batch 6.
442
+ let transport = crate::transport_factory::tmux_workspace_transport(workspace);
438
443
  let spawned = ensure_managed_leader_pane(&transport, session, window, plan, workspace)?;
439
444
  persist_managed_leader_binding(plan, workspace, &spawned)?;
440
445
  spawn_managed_provider_startup_prompt_handler(
@@ -1029,7 +1034,9 @@ fn spawn_managed_provider_startup_prompt_handler(
1029
1034
  pane_id: String,
1030
1035
  ) {
1031
1036
  std::thread::spawn(move || {
1032
- let transport = TmuxBackend::for_workspace(&workspace);
1037
+ // Phase 1d Batch 6: factory tmux workspace helper for
1038
+ // grep-visibility. Semantics unchanged.
1039
+ let transport = crate::transport_factory::tmux_workspace_transport(&workspace);
1033
1040
  let _ = handle_exec_provider_startup_prompts(
1034
1041
  provider,
1035
1042
  &workspace,
@@ -1042,9 +1049,13 @@ fn spawn_managed_provider_startup_prompt_handler(
1042
1049
  }
1043
1050
 
1044
1051
  fn tmux_transport_for_current_pane() -> TmuxBackend {
1052
+ // Phase 1d Batch 6: factory tmux channel helpers for
1053
+ // grep-visibility. Semantics unchanged; this is intentionally
1054
+ // tmux-only (caller pane = tmux `$TMUX` endpoint or default socket,
1055
+ // MUST-12 anchor).
1045
1056
  crate::tmux_backend::socket_name_from_tmux_env()
1046
- .map(|endpoint| TmuxBackend::for_tmux_endpoint(&endpoint))
1047
- .unwrap_or_else(TmuxBackend::new)
1057
+ .map(|endpoint| crate::transport_factory::tmux_endpoint_transport(&endpoint))
1058
+ .unwrap_or_else(crate::transport_factory::tmux_default_transport)
1048
1059
  }
1049
1060
 
1050
1061
  pub fn handle_exec_provider_startup_prompts(
@@ -1113,7 +1124,9 @@ fn provider_command_name(provider: Provider) -> &'static str {
1113
1124
  }
1114
1125
 
1115
1126
  fn tmux_session_exists(workspace: &Path, session: &SessionName) -> Result<bool, LeaderError> {
1116
- TmuxBackend::for_workspace(workspace)
1127
+ // Phase 1d Batch 6: factory tmux workspace helper for
1128
+ // grep-visibility. Tmux-only anchor (managed leader session lookup).
1129
+ crate::transport_factory::tmux_workspace_transport(workspace)
1117
1130
  .has_session(session)
1118
1131
  .map_err(|e| LeaderError::Start(format!("tmux has-session failed: {e}")))
1119
1132
  }
@@ -302,3 +302,175 @@ fn r8_requeued_exhausted_watchers_event_payload_golden_shape() {
302
302
  assert_eq!(payload.get("count").and_then(|v| v.as_u64()), Some(1), "count == number of requeued watchers");
303
303
  assert_eq!(payload.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
304
304
  }
305
+
306
+ #[test]
307
+ fn app_server_attach_writes_transport_kind_tuple_and_advances_epoch() {
308
+ let ws = std::env::temp_dir().join(format!(
309
+ "ta_rs_app_attach_{}_{}",
310
+ std::process::id(),
311
+ chrono::Utc::now().timestamp_millis()
312
+ ));
313
+ std::fs::create_dir_all(&ws).unwrap();
314
+ crate::state::persist::save_runtime_state(
315
+ &ws,
316
+ &serde_json::json!({
317
+ "active_team_key": "team-a",
318
+ "teams": {"team-a": {"team_key": "team-a", "agents": {}}},
319
+ }),
320
+ )
321
+ .unwrap();
322
+ let fake = crate::app_server_test_support::FakeAppServer::start(
323
+ "attach-ok",
324
+ crate::app_server_test_support::FakeAppServerScript::happy(
325
+ "thread-live",
326
+ "session-live",
327
+ ws.to_str().unwrap(),
328
+ ),
329
+ );
330
+
331
+ let out = attach_app_server_leader(&ws, Some("team-a"), fake.endpoint(), "thread-live")
332
+ .expect("app-server attach should succeed");
333
+
334
+ assert_eq!(out["ok"], serde_json::json!(true));
335
+ assert_eq!(out["leader_receiver"]["transport_kind"], serde_json::json!("codex_app_server"));
336
+ assert_eq!(out["leader_receiver"]["mode"], serde_json::json!("codex_app_server"));
337
+ assert_eq!(
338
+ out["leader_receiver"]["app_server"]["thread_id"],
339
+ serde_json::json!("thread-live")
340
+ );
341
+ assert_eq!(
342
+ out["leader_receiver"]["app_server"]["session_id"],
343
+ serde_json::json!("session-live")
344
+ );
345
+ assert_eq!(
346
+ out["leader_receiver"]["app_server"]["cwd"],
347
+ serde_json::json!(ws.to_str().unwrap())
348
+ );
349
+
350
+ let saved = crate::state::projection::select_runtime_state(&ws, Some("team-a")).unwrap();
351
+ assert_eq!(
352
+ saved["leader_receiver"]["app_server"]["thread_id"],
353
+ serde_json::json!("thread-live")
354
+ );
355
+ assert_eq!(saved["leader_receiver"].get("pane_id"), None);
356
+ assert_eq!(saved["team_owner"]["transport_kind"], serde_json::json!("codex_app_server"));
357
+ assert_eq!(saved["owner_epoch"], serde_json::json!(1));
358
+ }
359
+
360
+ #[test]
361
+ fn app_server_attach_rejects_world_writable_socket_without_state_write() {
362
+ let ws = std::env::temp_dir().join(format!(
363
+ "ta_rs_app_attach_badmode_{}_{}",
364
+ std::process::id(),
365
+ chrono::Utc::now().timestamp_millis()
366
+ ));
367
+ std::fs::create_dir_all(&ws).unwrap();
368
+ crate::state::persist::save_runtime_state(
369
+ &ws,
370
+ &serde_json::json!({"active_team_key": "team-a", "teams": {"team-a": {"agents": {}}}}),
371
+ )
372
+ .unwrap();
373
+ let fake = crate::app_server_test_support::FakeAppServer::start(
374
+ "attach-world",
375
+ crate::app_server_test_support::FakeAppServerScript::happy(
376
+ "thread-live",
377
+ "session-live",
378
+ ws.to_str().unwrap(),
379
+ ),
380
+ );
381
+ let mut perms = std::fs::metadata(fake.path()).unwrap().permissions();
382
+ use std::os::unix::fs::PermissionsExt;
383
+ perms.set_mode(0o777);
384
+ std::fs::set_permissions(fake.path(), perms).unwrap();
385
+
386
+ let err = attach_app_server_leader(&ws, Some("team-a"), fake.endpoint(), "thread-live")
387
+ .expect_err("world-writable app-server socket must be rejected");
388
+ assert!(
389
+ err.to_string().contains("socket_ownership_invalid"),
390
+ "unexpected error: {err}"
391
+ );
392
+ let saved = crate::state::projection::select_runtime_state(&ws, Some("team-a")).unwrap();
393
+ assert!(
394
+ saved.get("leader_receiver").is_none(),
395
+ "failed attach must not write leader_receiver: {saved}"
396
+ );
397
+ }
398
+
399
+ #[test]
400
+ fn app_server_attach_rejects_missing_user_agent_without_state_write() {
401
+ let ws = std::env::temp_dir().join(format!(
402
+ "ta_rs_app_attach_no_ua_{}_{}",
403
+ std::process::id(),
404
+ chrono::Utc::now().timestamp_millis()
405
+ ));
406
+ std::fs::create_dir_all(&ws).unwrap();
407
+ crate::state::persist::save_runtime_state(
408
+ &ws,
409
+ &serde_json::json!({"active_team_key": "team-a", "teams": {"team-a": {"agents": {}}}}),
410
+ )
411
+ .unwrap();
412
+ let mut script = crate::app_server_test_support::FakeAppServerScript::happy(
413
+ "thread-live",
414
+ "session-live",
415
+ ws.to_str().unwrap(),
416
+ );
417
+ script.user_agent = None;
418
+ let fake = crate::app_server_test_support::FakeAppServer::start("attach-no-ua", script);
419
+
420
+ let err = attach_app_server_leader(&ws, Some("team-a"), fake.endpoint(), "thread-live")
421
+ .expect_err("missing initialize.userAgent must fail closed");
422
+ assert!(
423
+ err.to_string()
424
+ .contains("protocol_mismatch_missing_user_agent"),
425
+ "unexpected error: {err}"
426
+ );
427
+ let saved = crate::state::projection::select_runtime_state(&ws, Some("team-a")).unwrap();
428
+ assert!(
429
+ saved.get("leader_receiver").is_none(),
430
+ "failed attach must not write leader_receiver: {saved}"
431
+ );
432
+ }
433
+
434
+ #[test]
435
+ fn app_server_delivery_paths_are_read_only_and_binding_entry_is_explicit() {
436
+ let delivery = include_str!("../../messaging/delivery.rs");
437
+ assert!(
438
+ !delivery.contains("write_owner(")
439
+ && !delivery.contains("with_leader_receiver(")
440
+ && !delivery.contains("with_team_owner("),
441
+ "MUST-12/I-RN-1: delivery may read typed leader_receiver fields but must not write ownership"
442
+ );
443
+ let lease = include_str!("../lease.rs");
444
+ assert!(
445
+ lease.contains("fn write_leader_receiver_transport("),
446
+ "C-5: mode and transport_kind must be stamped by a single receiver transport helper"
447
+ );
448
+ let cli = include_str!("../../cli/attach_app_server_leader.rs");
449
+ assert!(
450
+ cli.contains("attach_app_server_leader("),
451
+ "I-RN-2: app-server ownership mutation must be reachable through the explicit CLI entry"
452
+ );
453
+ }
454
+
455
+ #[test]
456
+ fn messaging_leader_receiver_module_exports_no_ownership_writer() {
457
+ let leader_receiver = include_str!("../../messaging/leader_receiver.rs");
458
+ for forbidden in [
459
+ "pub fn claim_leader_receiver",
460
+ "write_owner(",
461
+ "with_leader_receiver(",
462
+ "with_team_owner(",
463
+ "save_runtime_state",
464
+ ] {
465
+ assert!(
466
+ !leader_receiver.contains(forbidden),
467
+ "MUST-12/I-RN-1: messaging/leader_receiver.rs may read receiver fields but must not expose ownership writes; forbidden={forbidden}"
468
+ );
469
+ }
470
+
471
+ let messaging_mod = include_str!("../../messaging/mod.rs");
472
+ assert!(
473
+ !messaging_mod.contains("claim_leader_receiver"),
474
+ "MUST-12/I-RN-2: messaging/mod.rs must not re-export ownership writer APIs"
475
+ );
476
+ }
@@ -92,4 +92,27 @@ pub mod fake_worker;
92
92
  // tmux_backend — concrete tmux Transport executor (Command::new tmux via a CommandRunner seam);
93
93
  // the real spawn/capture/inject/has_session backend the daemon + launch use (step 9 shipped only
94
94
  // the trait + argv-builders). Real subprocess execution is the #[ignore] real-machine boundary.
95
+ pub mod codex_app_server;
95
96
  pub mod tmux_backend;
97
+
98
+ // 0.5.x Windows-native transport Phase 1: ConPTY backend + named-pipe
99
+ // protocol. `protocol` is portable (pure logic, tested on all hosts);
100
+ // `backend` compiles on all hosts and returns typed MuxUnavailable when
101
+ // no pipe client is wired (honest degradation, not silent success).
102
+ //
103
+ // Truth source:
104
+ // - Design: `.team/artifacts/0.5.x-windows-native-transport-design.md`
105
+ // §Transport Boundary + §Named Pipe Control Protocol + §Phase 1
106
+ // - CR verdict: `.team/artifacts/0.5.x-windows-transport-cr-verdict.md`
107
+ // (7 constraints; C-1/C-2/C-3/C-5 anchor into this module)
108
+ pub mod conpty;
109
+
110
+ // 0.5.x Phase 1d Batch 0: backend assembly factory.
111
+ // Truth sources:
112
+ // - Design: `.team/artifacts/0.5.x-backend-assembly-factory-design.md`
113
+ // - CR verdict: `.team/artifacts/0.5.x-backend-factory-cr-verdict.md`
114
+ // (6 constraints anchored inside the module doc)
115
+ pub mod transport_factory;
116
+
117
+ #[cfg(test)]
118
+ pub(crate) mod app_server_test_support;