@team-agent/installer 0.5.2 → 0.5.4

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 (51) hide show
  1. package/Cargo.lock +3 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +20 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +191 -31
  5. package/crates/team-agent/src/cli/emit.rs +5 -0
  6. package/crates/team-agent/src/cli/mod.rs +213 -89
  7. package/crates/team-agent/src/cli/tests/named_address.rs +4 -0
  8. package/crates/team-agent/src/codex_app_server.rs +62 -1
  9. package/crates/team-agent/src/conpty/backend.rs +120 -8
  10. package/crates/team-agent/src/coordinator/backoff.rs +88 -2
  11. package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
  12. package/crates/team-agent/src/coordinator/health.rs +97 -11
  13. package/crates/team-agent/src/coordinator/mod.rs +8 -0
  14. package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
  15. package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
  16. package/crates/team-agent/src/coordinator/tick.rs +13 -0
  17. package/crates/team-agent/src/diagnose/orphans.rs +18 -7
  18. package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
  19. package/crates/team-agent/src/leader/tests/lease_api.rs +7 -0
  20. package/crates/team-agent/src/lib.rs +14 -1
  21. package/crates/team-agent/src/lifecycle/launch.rs +89 -92
  22. package/crates/team-agent/src/lifecycle/lock.rs +40 -33
  23. package/crates/team-agent/src/lifecycle/restart/agent.rs +25 -19
  24. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -2
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -6
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +47 -18
  27. package/crates/team-agent/src/lifecycle/restart.rs +16 -6
  28. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +287 -53
  30. package/crates/team-agent/src/lifecycle/tests/restart.rs +144 -5
  31. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  32. package/crates/team-agent/src/mcp_server/wire.rs +6 -7
  33. package/crates/team-agent/src/messaging/tests/runtime.rs +5 -0
  34. package/crates/team-agent/src/packaging/tests.rs +41 -6
  35. package/crates/team-agent/src/packaging/types.rs +31 -3
  36. package/crates/team-agent/src/platform/argv.rs +324 -0
  37. package/crates/team-agent/src/platform/errors.rs +95 -0
  38. package/crates/team-agent/src/platform/file_lock.rs +418 -0
  39. package/crates/team-agent/src/platform/mod.rs +66 -0
  40. package/crates/team-agent/src/platform/process.rs +555 -0
  41. package/crates/team-agent/src/provider/adapter.rs +103 -41
  42. package/crates/team-agent/src/provider/session/capture.rs +328 -66
  43. package/crates/team-agent/src/provider/session/resume.rs +30 -28
  44. package/crates/team-agent/src/provider/session_scan/common.rs +117 -1
  45. package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
  46. package/crates/team-agent/src/provider/session_scan.rs +1 -0
  47. package/crates/team-agent/src/state/persist.rs +222 -25
  48. package/crates/team-agent/src/state/projection.rs +59 -4
  49. package/crates/team-agent/src/tmux_backend.rs +63 -13
  50. package/crates/team-agent/src/transport_factory.rs +124 -5
  51. package/package.json +4 -4
@@ -101,11 +101,8 @@ fn layer2_backing_missing_refusal_carries_checked_paths_and_recovery_hint() {
101
101
  use std::sync::atomic::{AtomicU32, Ordering};
102
102
  static N: AtomicU32 = AtomicU32::new(0);
103
103
  let n = N.fetch_add(1, Ordering::Relaxed);
104
- let ws = std::env::temp_dir().join(format!(
105
- "ta_rs_l2_backingmiss_{}_{}",
106
- std::process::id(),
107
- n
108
- ));
104
+ let ws =
105
+ std::env::temp_dir().join(format!("ta_rs_l2_backingmiss_{}_{}", std::process::id(), n));
109
106
  std::fs::create_dir_all(&ws).unwrap();
110
107
 
111
108
  let missing_rollout = ws.join(".missing-rollout.jsonl");
@@ -168,6 +165,148 @@ fn layer2_backing_missing_refusal_carries_checked_paths_and_recovery_hint() {
168
165
  }
169
166
  }
170
167
 
168
+ fn write_codex_identity_rollout(
169
+ workspace: &std::path::Path,
170
+ file_name: &str,
171
+ session_id: &str,
172
+ embedded_agent_id: &str,
173
+ ) -> std::path::PathBuf {
174
+ let path = workspace.join(file_name);
175
+ let text = format!(
176
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
177
+ {{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
178
+ {{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
179
+ workspace.to_string_lossy()
180
+ );
181
+ std::fs::write(&path, text).unwrap();
182
+ path
183
+ }
184
+
185
+ #[test]
186
+ fn restart_refuses_codex_session_identity_mismatch_without_allow_fresh() {
187
+ let ws = temp_ws();
188
+ let rollout = write_codex_identity_rollout(
189
+ &ws,
190
+ "rollout-frontend-poison.jsonl",
191
+ "019f3327-c35a-7023-b3cd-1bea93a7a157",
192
+ "ios-dev",
193
+ );
194
+ let state = json!({
195
+ "agents": {
196
+ "frontend": {
197
+ "provider": "codex",
198
+ "status": "running",
199
+ "session_id": "019f3327-c35a-7023-b3cd-1bea93a7a157",
200
+ "rollout_path": rollout.to_string_lossy(),
201
+ "captured_at": "2026-07-05T17:04:04Z",
202
+ "captured_via": "fs_watch",
203
+ "spawn_cwd": ws.to_string_lossy(),
204
+ "first_send_at": "2026-07-05T17:10:00Z"
205
+ }
206
+ }
207
+ });
208
+
209
+ let plan = crate::lifecycle::restart::classify_restart_plan_with_resume_validation(
210
+ Some(&ws),
211
+ &state,
212
+ false,
213
+ )
214
+ .unwrap();
215
+
216
+ assert_eq!(plan.decisions.len(), 1);
217
+ assert_eq!(plan.decisions[0].decision, ResumeDecision::Refuse);
218
+ assert_eq!(plan.unresumable.len(), 1);
219
+ let entry = &plan.unresumable[0];
220
+ assert_eq!(entry.agent_id.as_str(), "frontend");
221
+ assert_eq!(entry.reason, "session_identity_mismatch");
222
+ assert_eq!(
223
+ entry
224
+ .refusal_reason
225
+ .as_ref()
226
+ .map(crate::provider::session::ResumeRefusalReason::wire),
227
+ Some("session_identity_mismatch")
228
+ );
229
+ }
230
+
231
+ #[test]
232
+ fn restart_identity_probe_reports_real_frontend_ios_dev_mismatch_shape() {
233
+ let ws = temp_ws();
234
+ let rollout = write_codex_identity_rollout(
235
+ &ws,
236
+ "rollout-frontend-poison.jsonl",
237
+ "019f3327-c35a-7023-b3cd-1bea93a7a157",
238
+ "ios-dev",
239
+ );
240
+ let rollout_path = crate::provider::RolloutPath::new(rollout.clone());
241
+ let probe = crate::lifecycle::restart::session_identity_probe_for_agent(
242
+ &aid("frontend"),
243
+ crate::provider::Provider::Codex,
244
+ Some(&rollout_path),
245
+ );
246
+ assert_eq!(probe.identity_ok, Some(false));
247
+ assert_eq!(probe.embedded_agent_id.as_deref(), Some("ios-dev"));
248
+ assert_eq!(probe.rollout_path.as_deref(), Some(rollout.as_path()));
249
+ }
250
+
251
+ #[test]
252
+ fn restart_allow_fresh_only_fresh_starts_mismatched_codex_worker() {
253
+ let ws = temp_ws();
254
+ let frontend_rollout = write_codex_identity_rollout(
255
+ &ws,
256
+ "rollout-frontend-poison.jsonl",
257
+ "019f3327-c35a-7023-b3cd-1bea93a7a157",
258
+ "ios-dev",
259
+ );
260
+ let backend_rollout = write_codex_identity_rollout(
261
+ &ws,
262
+ "rollout-backend-valid.jsonl",
263
+ "019f3327-backend-valid",
264
+ "backend",
265
+ );
266
+ let state = json!({
267
+ "agents": {
268
+ "frontend": {
269
+ "provider": "codex",
270
+ "status": "running",
271
+ "session_id": "019f3327-c35a-7023-b3cd-1bea93a7a157",
272
+ "rollout_path": frontend_rollout.to_string_lossy(),
273
+ "captured_at": "2026-07-05T17:04:04Z",
274
+ "captured_via": "fs_watch",
275
+ "spawn_cwd": ws.to_string_lossy()
276
+ },
277
+ "backend": {
278
+ "provider": "codex",
279
+ "status": "running",
280
+ "session_id": "019f3327-backend-valid",
281
+ "rollout_path": backend_rollout.to_string_lossy(),
282
+ "captured_at": "2026-07-05T17:04:05Z",
283
+ "captured_via": "fs_watch",
284
+ "spawn_cwd": ws.to_string_lossy()
285
+ }
286
+ }
287
+ });
288
+
289
+ let plan = crate::lifecycle::restart::classify_restart_plan_with_resume_validation(
290
+ Some(&ws),
291
+ &state,
292
+ true,
293
+ )
294
+ .unwrap();
295
+
296
+ assert!(
297
+ plan.unresumable.is_empty(),
298
+ "allow_fresh should convert the poisoned worker to fresh without refusing; got {:?}",
299
+ plan.unresumable
300
+ );
301
+ let decisions = plan
302
+ .decisions
303
+ .iter()
304
+ .map(|decision| (decision.agent_id.as_str(), decision.decision))
305
+ .collect::<std::collections::BTreeMap<_, _>>();
306
+ assert_eq!(decisions.get("frontend"), Some(&ResumeDecision::FreshStart));
307
+ assert_eq!(decisions.get("backend"), Some(&ResumeDecision::Resume));
308
+ }
309
+
171
310
  #[test]
172
311
  fn unit0_restart_corrupt_first_send_at_blocks_before_resume_classification() {
173
312
  // The corrupt-first_send_at branch is the hard-refuse gate that fires
@@ -440,6 +440,7 @@ pub struct StartedAgent {
440
440
  pub agent_id: AgentId,
441
441
  pub start_mode: StartMode,
442
442
  pub target: String,
443
+ pub spawned_at: String,
443
444
  pub session_id: Option<SessionId>,
444
445
  pub rollout_path: Option<RolloutPath>,
445
446
  pub pending_session_id: Option<SessionId>,
@@ -314,14 +314,13 @@ fn non_empty_env(key: &str) -> Option<String> {
314
314
  .filter(|value| !value.is_empty())
315
315
  }
316
316
 
317
- #[cfg(unix)]
317
+ // 0.5.x Windows portability Batch 3: parent-pid probe routes through
318
+ // `crate::platform::process::current_parent_pid`. Unix uses
319
+ // `libc::getppid`; Windows uses Toolhelp32Snapshot. Both return
320
+ // `Option<u32>`; the legacy `0` fallback (which would have silently
321
+ // degraded MCP metadata + orphan detection on Windows) is gone.
318
322
  fn parent_pid() -> u32 {
319
- u32::try_from(unsafe { libc::getppid() }).unwrap_or(0)
320
- }
321
-
322
- #[cfg(not(unix))]
323
- fn parent_pid() -> u32 {
324
- 0
323
+ crate::platform::process::current_parent_pid().unwrap_or(0)
325
324
  }
326
325
 
327
326
  fn error_response_value(id: RpcId, code: i64, message: String) -> Value {
@@ -1932,6 +1932,9 @@ fn app_server_state(
1932
1932
  })
1933
1933
  }
1934
1934
 
1935
+ // 0.5.x Windows portability Batch 5: FakeAppServer uses Unix domain
1936
+ // sockets — Unix-only.
1937
+ #[cfg(unix)]
1935
1938
  #[test]
1936
1939
  fn app_server_leader_delivery_marks_delivered_without_tmux_transport() {
1937
1940
  let ws = tmp_ws("appdeliver");
@@ -1984,6 +1987,7 @@ fn app_server_leader_delivery_marks_delivered_without_tmux_transport() {
1984
1987
  );
1985
1988
  }
1986
1989
 
1990
+ #[cfg(unix)]
1987
1991
  #[test]
1988
1992
  fn app_server_leader_delivery_stale_tuple_fails_closed_with_event() {
1989
1993
  let ws = tmp_ws("appstale");
@@ -2076,6 +2080,7 @@ fn app_server_leader_delivery_fails_closed_on_mode_transport_conflict() {
2076
2080
  );
2077
2081
  }
2078
2082
 
2083
+ #[cfg(unix)]
2079
2084
  #[test]
2080
2085
  fn app_server_leader_busy_is_retryable_and_does_not_steer() {
2081
2086
  let ws = tmp_ws("appbusy");
@@ -222,12 +222,47 @@ fn linux_aarch64_is_native() {
222
222
  }
223
223
 
224
224
  #[test]
225
- fn windows_x8664_is_native_per_transport_design() {
226
- // skeleton:203/211 Windows 原生一等 (WezTerm/ConPTY,非 tmux)
227
- // 不是 Unsupported,不是 RequiresWslTmux 是 Native.
228
- assert_eq!(
229
- platform_support(ReleaseTarget::WindowsX8664),
230
- PlatformSupport::Native
225
+ fn windows_x8664_is_preview_compile_only_during_batch_0_to_5() {
226
+ // 0.5.x Windows portability CR C-1 (P0):
227
+ // Windows previously claimed `PlatformSupport::Native` but
228
+ // `cargo check --target x86_64-pc-windows-msvc` was RED — the
229
+ // Unix-only `std::os::unix` / `libc::pid_t` calls in
230
+ // tmux_backend/coordinator/lifecycle/packaging never compiled on
231
+ // Windows. Claiming Native was a MUST-NOT-13 假绿承诺 (user
232
+ // installs and immediately hits build failures).
233
+ //
234
+ // This test locks the honest downgrade in place until Batch 6/7
235
+ // real-machine gates pass. Promoting Windows back to `Native`
236
+ // requires:
237
+ // 1. Batch 6 fake-provider smoke on the SSH host (design §Batch 6)
238
+ // 2. Batch 7 real-machine subscription serial (CR §C-5)
239
+ // Both flip this test to `assert PlatformSupport::Native` in a
240
+ // deliberate PR — no silent regen.
241
+ //
242
+ // Truth source: `.team/artifacts/0.5.x-windows-portability-cr-verdict.md` §C-1.
243
+ match platform_support(ReleaseTarget::WindowsX8664) {
244
+ PlatformSupport::PreviewCompileOnly { preview_gate, note } => {
245
+ assert_eq!(preview_gate, "compile_gate");
246
+ assert!(note.contains("compile_gate") || note.contains("cargo check"));
247
+ }
248
+ other => panic!(
249
+ "Windows must stay `PreviewCompileOnly` while the compile gate is RED; \
250
+ promoting back to `Native` requires Batch 6 + Batch 7 real-machine gates. \
251
+ Got {other:?}"
252
+ ),
253
+ }
254
+ }
255
+
256
+ #[test]
257
+ fn windows_x8664_is_not_native_yet_c1_hard_lock() {
258
+ // Belt-and-braces companion for the above — a distinct assertion
259
+ // that fires clearly if a future refactor of `PlatformSupport`
260
+ // changes variant names.
261
+ let support = platform_support(ReleaseTarget::WindowsX8664);
262
+ assert_ne!(
263
+ support,
264
+ PlatformSupport::Native,
265
+ "CR C-1: Windows must NOT be Native until Batch 6/7 gates pass"
231
266
  );
232
267
  }
233
268
 
@@ -162,13 +162,27 @@ pub enum ReleaseTarget {
162
162
  }
163
163
 
164
164
  /// 平台支持等级(§8:如实声明,不假装兼容)。
165
+ ///
166
+ /// 0.5.x Windows portability CR C-1 (P0) 引入 `PreviewCompileOnly`:
167
+ /// Windows 目前 `cargo check --target x86_64-pc-windows-msvc` **仍红**
168
+ /// (三处 non-Unix fallback + platform 层未接入 + Batch 1-4 未完成);
169
+ /// 保持 `Native` 会构成 MUST-NOT-13 假绿承诺 → 用户装 Windows 二进制
170
+ /// 会真报错。降级为 `PreviewCompileOnly` 明示"当前 Windows 尚未通过
171
+ /// 编译门,Batch 6/7 真机订阅测通过前不承诺 Native"。
172
+ /// Truth source: `.team/artifacts/0.5.x-windows-portability-cr-verdict.md` §C-1。
165
173
  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166
174
  #[serde(rename_all = "snake_case", tag = "level")]
167
175
  pub enum PlatformSupport {
168
- /// 原生一等(macOS/Linux tmux;Windows WezTerm/ConPTY,见 transport-backend-design.md)。
176
+ /// 原生一等(macOS/Linux tmux)。
169
177
  Native,
170
178
  /// 需 WSL + tmux backend(显式标要求,不假装原生)。
171
179
  RequiresWslTmux { note: String },
180
+ /// Windows 移植进行中,`cargo check` 仍红或 Batch 6/7 真机订阅测未过。
181
+ /// 用户不应期待可运行的 `.exe`;`preview_gate` 字段说明当前挡位
182
+ /// (`compile_gate` = Batch 0-4 编译门,`fake_provider_smoke` = Batch 6,
183
+ /// `subscription_realmachine` = Batch 7)。
184
+ /// 该档位不承诺可用,仅承诺"正在移植 + 每 Batch 有 CI 编译门追踪"。
185
+ PreviewCompileOnly { preview_gate: String, note: String },
172
186
  /// 当前 release 矩阵不覆盖。
173
187
  Unsupported { reason: String },
174
188
  }
@@ -179,8 +193,22 @@ pub fn platform_support(target: ReleaseTarget) -> PlatformSupport {
179
193
  ReleaseTarget::MacosAarch64
180
194
  | ReleaseTarget::MacosX8664
181
195
  | ReleaseTarget::LinuxX8664
182
- | ReleaseTarget::LinuxAarch64
183
- | ReleaseTarget::WindowsX8664 => PlatformSupport::Native,
196
+ | ReleaseTarget::LinuxAarch64 => PlatformSupport::Native,
197
+ // 0.5.x Windows portability CR C-1 (P0):
198
+ // `cargo check --target x86_64-pc-windows-msvc` 是当前挡位;
199
+ // Batch 6 fake-provider smoke + Batch 7 真机订阅测通过后
200
+ // 才升回 `Native`。历史 tag 期间 Windows 曾声明为 Native 但
201
+ // 从未通过编译门,构成 MUST-NOT-13 假绿承诺 → 本 batch 降级。
202
+ ReleaseTarget::WindowsX8664 => PlatformSupport::PreviewCompileOnly {
203
+ preview_gate: "compile_gate".to_string(),
204
+ note: "Windows native port in progress; `cargo check --target x86_64-pc-windows-msvc` \
205
+ is the current gate. See \
206
+ `.team/artifacts/0.5.x-windows-portability-survey-design.md` §Batch 0-6 \
207
+ and CR verdict §C-1 for burn-down status. Promote back to `Native` only \
208
+ after Batch 6 (fake-provider smoke) + Batch 7 (subscription real-machine) \
209
+ pass on the SSH host."
210
+ .to_string(),
211
+ },
184
212
  }
185
213
  }
186
214
 
@@ -0,0 +1,324 @@
1
+ //! Process argv / environ probe.
2
+ //!
3
+ //! ## Batch 4 real implementation (leader msg_0689a63a9e40)
4
+ //!
5
+ //! Batch 4 promotes this module from a Batch 0 signature-only
6
+ //! scaffold to a **byte-preserving migration** of the process ancestry
7
+ //! + argv/env probes used by:
8
+ //!
9
+ //! - `lifecycle/launch.rs::process_ancestry_argv` +
10
+ //! `process_argv_tokens` + `process_parent_pid` — the
11
+ //! `dangerous_auto_approve` inheritance chain (0.5.0 caller-identity
12
+ //! 钉输入 is exactly this test surface).
13
+ //! - `leader/provider_attribution.rs::process_command_line` +
14
+ //! `process_environment` — leader provider attribution.
15
+ //!
16
+ //! ## Platform matrix
17
+ //!
18
+ //! - **Linux**: `/proc/<pid>/cmdline` (NUL-separated argv) and
19
+ //! `/proc/<pid>/environ` (NUL-separated KEY=VALUE).
20
+ //! - **macOS**: `sysctl(KERN_PROCARGS2)` for argv; environ not
21
+ //! currently probed (matches the pre-batch behavior).
22
+ //! - **non-Linux non-macOS Unix**: `ps -p <pid> -o command=` for argv.
23
+ //! - **Windows**: `NtQueryInformationProcess` + PEB is intrusive; for
24
+ //! Batch 4 we return `None` for both `argv_tokens` and
25
+ //! `environ_text` (design §Batch 4 Verification anchor: "unknown
26
+ //! argv must never infer elevated approval; keep worker permission
27
+ //! at provider default or require explicit user consent"). This is
28
+ //! the honest "we don't know" branch — callers already treat
29
+ //! `None` as "no elevation inherited", so Windows leaders default
30
+ //! to safe (non-dangerous) approval mode.
31
+ //!
32
+ //! ## CR C-3 anchor
33
+ //!
34
+ //! The `Option::None` return on Windows deliberately keeps worker
35
+ //! permission at provider default. Design §Batch 4 Verification: "the
36
+ //! safe direction is to avoid elevation, not to assume bypass" —
37
+ //! `detect_dangerous_approval` in `lifecycle/launch.rs` already
38
+ //! iterates `process_ancestry_argv(...)` and defaults to
39
+ //! `disabled_dangerous_approval()` when it finds no matching flag,
40
+ //! so a Windows probe returning `None` on every step lands on the
41
+ //! same disabled default. **This is intentional**: leaking a
42
+ //! bypass via unknown argv would be a MUST-NOT-13 假绿 failure.
43
+
44
+ /// Return the argv tokens for `pid`, or `None` if unavailable on
45
+ /// this platform.
46
+ ///
47
+ /// - Linux: `/proc/<pid>/cmdline` NUL-separated (byte-preserving
48
+ /// migration of `lifecycle/launch.rs:3648-3656`).
49
+ /// - macOS: `sysctl(KERN_PROCARGS2)` (migration of
50
+ /// `lifecycle/launch.rs:3659-3711`).
51
+ /// - Other Unix: `ps -p <pid> -o command=` split on whitespace
52
+ /// (migration of `lifecycle/launch.rs:3714-3729`).
53
+ /// - Windows: `None` (design §Batch 4 conservative fallback — never
54
+ /// infer elevated approval from unknown argv).
55
+ pub fn argv_tokens(pid: u32) -> Option<Vec<String>> {
56
+ #[cfg(target_os = "linux")]
57
+ {
58
+ argv_tokens_linux(pid)
59
+ }
60
+ #[cfg(target_os = "macos")]
61
+ {
62
+ argv_tokens_macos(pid)
63
+ }
64
+ #[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
65
+ {
66
+ argv_tokens_ps_fallback(pid)
67
+ }
68
+ #[cfg(windows)]
69
+ {
70
+ let _ = pid;
71
+ None
72
+ }
73
+ }
74
+
75
+ /// Return the environ text (NUL-joined) for `pid`, or `None`.
76
+ ///
77
+ /// - Linux: `/proc/<pid>/environ` verbatim (migration of
78
+ /// `leader/provider_attribution.rs:113-115`).
79
+ /// - Other platforms: `None` (matches the pre-batch
80
+ /// `#[cfg(not(target_os="linux"))]` stub in
81
+ /// `leader/provider_attribution.rs:135-139`).
82
+ pub fn environ_text(pid: u32) -> Option<String> {
83
+ #[cfg(target_os = "linux")]
84
+ {
85
+ environ_text_linux(pid)
86
+ }
87
+ #[cfg(not(target_os = "linux"))]
88
+ {
89
+ let _ = pid;
90
+ None
91
+ }
92
+ }
93
+
94
+ /// Return the parent PID of `pid`, or `None` if unavailable.
95
+ ///
96
+ /// Byte-preserving migration of `lifecycle/launch.rs::process_parent_pid`:
97
+ /// - Unix: `ps -p <pid> -o ppid=` parsed as u32.
98
+ /// - Windows: Toolhelp32 snapshot walked for the entry matching
99
+ /// `pid`. Same technique as `platform::process::current_parent_pid`.
100
+ pub fn parent_pid(pid: u32) -> Option<u32> {
101
+ #[cfg(unix)]
102
+ {
103
+ parent_pid_unix(pid)
104
+ }
105
+ #[cfg(windows)]
106
+ {
107
+ parent_pid_windows(pid)
108
+ }
109
+ #[cfg(not(any(unix, windows)))]
110
+ {
111
+ let _ = pid;
112
+ None
113
+ }
114
+ }
115
+
116
+ // ─────────────────────────────────────────────────────────────────────
117
+ // Linux implementation.
118
+ // ─────────────────────────────────────────────────────────────────────
119
+
120
+ #[cfg(target_os = "linux")]
121
+ fn argv_tokens_linux(pid: u32) -> Option<Vec<String>> {
122
+ let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
123
+ let argv_tokens = String::from_utf8_lossy(&bytes)
124
+ .split('\0')
125
+ .filter(|token| !token.is_empty())
126
+ .map(str::to_string)
127
+ .collect::<Vec<_>>();
128
+ (!argv_tokens.is_empty()).then_some(argv_tokens)
129
+ }
130
+
131
+ #[cfg(target_os = "linux")]
132
+ fn environ_text_linux(pid: u32) -> Option<String> {
133
+ String::from_utf8(std::fs::read(format!("/proc/{pid}/environ")).ok()?).ok()
134
+ }
135
+
136
+ // ─────────────────────────────────────────────────────────────────────
137
+ // macOS implementation.
138
+ // ─────────────────────────────────────────────────────────────────────
139
+
140
+ #[cfg(target_os = "macos")]
141
+ fn argv_tokens_macos(pid: u32) -> Option<Vec<String>> {
142
+ use std::mem::size_of;
143
+ let mut mib = [
144
+ libc::CTL_KERN,
145
+ libc::KERN_PROCARGS2,
146
+ i32::try_from(pid).ok()?,
147
+ ];
148
+ let mut size = 0usize;
149
+ let rc = unsafe {
150
+ libc::sysctl(
151
+ mib.as_mut_ptr(),
152
+ mib.len() as u32,
153
+ std::ptr::null_mut(),
154
+ &mut size,
155
+ std::ptr::null_mut(),
156
+ 0,
157
+ )
158
+ };
159
+ if rc != 0 || size <= size_of::<libc::c_int>() {
160
+ return None;
161
+ }
162
+ let mut buf = vec![0u8; size];
163
+ let rc = unsafe {
164
+ libc::sysctl(
165
+ mib.as_mut_ptr(),
166
+ mib.len() as u32,
167
+ buf.as_mut_ptr().cast(),
168
+ &mut size,
169
+ std::ptr::null_mut(),
170
+ 0,
171
+ )
172
+ };
173
+ if rc != 0 || size <= size_of::<libc::c_int>() {
174
+ return None;
175
+ }
176
+ let argc = i32::from_ne_bytes(buf.get(..size_of::<libc::c_int>())?.try_into().ok()?) as usize;
177
+ let mut offset = size_of::<libc::c_int>();
178
+ while offset < size && buf[offset] != 0 {
179
+ offset += 1;
180
+ }
181
+ while offset < size && buf[offset] == 0 {
182
+ offset += 1;
183
+ }
184
+ let raw = String::from_utf8_lossy(&buf[offset..size]);
185
+ let argv_tokens = raw
186
+ .split('\0')
187
+ .filter(|token| !token.is_empty())
188
+ .take(argc)
189
+ .map(str::to_string)
190
+ .collect::<Vec<_>>();
191
+ (!argv_tokens.is_empty()).then_some(argv_tokens)
192
+ }
193
+
194
+ // ─────────────────────────────────────────────────────────────────────
195
+ // Generic Unix fallback (`ps -p <pid> -o ...`).
196
+ // ─────────────────────────────────────────────────────────────────────
197
+
198
+ #[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
199
+ fn argv_tokens_ps_fallback(pid: u32) -> Option<Vec<String>> {
200
+ let output = std::process::Command::new("ps")
201
+ .args(["-p", &pid.to_string(), "-o", "command="])
202
+ .output()
203
+ .ok()?;
204
+ if !output.status.success() {
205
+ return None;
206
+ }
207
+ let text = String::from_utf8_lossy(&output.stdout);
208
+ let argv_tokens = text
209
+ .split_whitespace()
210
+ .filter(|token| !token.is_empty())
211
+ .map(str::to_string)
212
+ .collect::<Vec<_>>();
213
+ (!argv_tokens.is_empty()).then_some(argv_tokens)
214
+ }
215
+
216
+ #[cfg(unix)]
217
+ fn parent_pid_unix(pid: u32) -> Option<u32> {
218
+ let output = std::process::Command::new("ps")
219
+ .args(["-p", &pid.to_string(), "-o", "ppid="])
220
+ .output()
221
+ .ok()?;
222
+ if !output.status.success() {
223
+ return None;
224
+ }
225
+ String::from_utf8_lossy(&output.stdout)
226
+ .trim()
227
+ .parse::<u32>()
228
+ .ok()
229
+ }
230
+
231
+ // ─────────────────────────────────────────────────────────────────────
232
+ // Windows implementation.
233
+ // ─────────────────────────────────────────────────────────────────────
234
+
235
+ #[cfg(windows)]
236
+ fn parent_pid_windows(pid: u32) -> Option<u32> {
237
+ use windows::Win32::System::Diagnostics::ToolHelp::{
238
+ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
239
+ TH32CS_SNAPPROCESS,
240
+ };
241
+ let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?;
242
+ let mut entry = PROCESSENTRY32W {
243
+ dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
244
+ ..Default::default()
245
+ };
246
+ let mut result = unsafe { Process32FirstW(snapshot, &mut entry) };
247
+ while result.is_ok() {
248
+ if entry.th32ProcessID == pid {
249
+ let ppid = entry.th32ParentProcessID;
250
+ unsafe {
251
+ let _ = windows::Win32::Foundation::CloseHandle(snapshot);
252
+ }
253
+ return Some(ppid);
254
+ }
255
+ result = unsafe { Process32NextW(snapshot, &mut entry) };
256
+ }
257
+ unsafe {
258
+ let _ = windows::Win32::Foundation::CloseHandle(snapshot);
259
+ }
260
+ None
261
+ }
262
+
263
+ // ─────────────────────────────────────────────────────────────────────
264
+ // Tests.
265
+ // ─────────────────────────────────────────────────────────────────────
266
+
267
+ #[cfg(test)]
268
+ mod tests {
269
+ use super::*;
270
+
271
+ #[test]
272
+ fn argv_tokens_returns_some_for_own_pid_on_unix_or_none_on_windows() {
273
+ // Byte-preserving migration of the ancestry probe. On Unix
274
+ // this must return a non-empty argv for our own process (test
275
+ // binary). On Windows the honest `None` return is intentional:
276
+ // callers see the "no elevation inherited" default branch,
277
+ // which is safer than reading unknown argv (CR C-3 anchor).
278
+ let my = std::process::id();
279
+ let argv = argv_tokens(my);
280
+ #[cfg(unix)]
281
+ {
282
+ let argv = argv.expect("unix must return Some argv for own pid");
283
+ assert!(!argv.is_empty());
284
+ // At least argv[0] should look like a test binary path.
285
+ let argv0 = &argv[0];
286
+ assert!(!argv0.is_empty());
287
+ }
288
+ #[cfg(windows)]
289
+ {
290
+ // Windows conservative fallback: None, so
291
+ // `detect_dangerous_approval` defaults to disabled.
292
+ assert!(argv.is_none());
293
+ }
294
+ }
295
+
296
+ #[test]
297
+ fn parent_pid_returns_some_on_both_platforms() {
298
+ // Batch 4: parent_pid via `ps -o ppid=` (unix) or Toolhelp32
299
+ // (windows) — both must resolve our own ppid.
300
+ let my = std::process::id();
301
+ let ppid = parent_pid(my);
302
+ assert!(ppid.is_some(), "parent_pid must resolve own pid on both unix and windows");
303
+ assert!(ppid.unwrap() > 0);
304
+ }
305
+
306
+ #[test]
307
+ fn environ_text_linux_only_returns_something_for_own_pid() {
308
+ // The environ probe is Linux-only by design (matches
309
+ // `leader/provider_attribution.rs:113-115`). Other platforms
310
+ // honestly return None; callers already treat that as
311
+ // "no env-based attribution".
312
+ let my = std::process::id();
313
+ let env = environ_text(my);
314
+ #[cfg(target_os = "linux")]
315
+ {
316
+ let env = env.expect("linux must return Some environ");
317
+ assert!(env.contains('='));
318
+ }
319
+ #[cfg(not(target_os = "linux"))]
320
+ {
321
+ assert!(env.is_none());
322
+ }
323
+ }
324
+ }