@team-agent/installer 0.5.2 → 0.5.3

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 (35) 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/emit.rs +5 -0
  5. package/crates/team-agent/src/cli/mod.rs +121 -56
  6. package/crates/team-agent/src/cli/tests/named_address.rs +4 -0
  7. package/crates/team-agent/src/codex_app_server.rs +62 -1
  8. package/crates/team-agent/src/conpty/backend.rs +120 -8
  9. package/crates/team-agent/src/coordinator/backoff.rs +88 -2
  10. package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
  11. package/crates/team-agent/src/coordinator/health.rs +97 -11
  12. package/crates/team-agent/src/coordinator/mod.rs +8 -0
  13. package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
  14. package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
  15. package/crates/team-agent/src/diagnose/orphans.rs +18 -7
  16. package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
  17. package/crates/team-agent/src/leader/tests/lease_api.rs +7 -0
  18. package/crates/team-agent/src/lib.rs +14 -1
  19. package/crates/team-agent/src/lifecycle/launch.rs +80 -91
  20. package/crates/team-agent/src/lifecycle/lock.rs +40 -33
  21. package/crates/team-agent/src/lifecycle/restart/agent.rs +10 -18
  22. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
  23. package/crates/team-agent/src/mcp_server/wire.rs +6 -7
  24. package/crates/team-agent/src/messaging/tests/runtime.rs +5 -0
  25. package/crates/team-agent/src/packaging/tests.rs +41 -6
  26. package/crates/team-agent/src/packaging/types.rs +31 -3
  27. package/crates/team-agent/src/platform/argv.rs +324 -0
  28. package/crates/team-agent/src/platform/errors.rs +95 -0
  29. package/crates/team-agent/src/platform/file_lock.rs +418 -0
  30. package/crates/team-agent/src/platform/mod.rs +66 -0
  31. package/crates/team-agent/src/platform/process.rs +555 -0
  32. package/crates/team-agent/src/state/persist.rs +205 -25
  33. package/crates/team-agent/src/tmux_backend.rs +63 -13
  34. package/crates/team-agent/src/transport_factory.rs +124 -5
  35. package/package.json +4 -4
@@ -559,25 +559,17 @@ pub(super) fn drain_old_pane_and_pid(
559
559
  })
560
560
  }
561
561
 
562
- /// Best-effort liveness probe for a pid. Returns true if the pid exists
563
- /// on Unix (signal 0). On other platforms, returns true conservatively.
562
+ /// Best-effort liveness probe for a pid.
563
+ ///
564
+ /// 0.5.x Windows portability Batch 3: routes through
565
+ /// `crate::platform::process::pid_is_alive`. Unix uses `kill(pid, 0)`
566
+ /// with the `EPERM = Live` branch preserved byte-for-byte; Windows
567
+ /// uses `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` +
568
+ /// `GetExitCodeProcess` (STILL_ACTIVE = Live). The legacy non-Unix
569
+ /// `true` fallback (which broke drain by reporting every pid alive)
570
+ /// is gone.
564
571
  pub(super) fn pid_is_alive(pid: u32) -> bool {
565
- #[cfg(unix)]
566
- {
567
- // SAFETY: kill(pid, 0) checks if pid exists without sending a signal.
568
- let ret = unsafe { libc::kill(pid as i32, 0) };
569
- if ret == 0 {
570
- return true;
571
- }
572
- // EPERM also means the process exists (we just can't signal it).
573
- let err = std::io::Error::last_os_error();
574
- matches!(err.raw_os_error(), Some(libc::EPERM))
575
- }
576
- #[cfg(not(unix))]
577
- {
578
- let _ = pid;
579
- true
580
- }
572
+ crate::platform::process::pid_is_alive(pid)
581
573
  }
582
574
 
583
575
  /// Read state.agents[agent_id].pane_pid (u32) from the runtime state.
@@ -287,6 +287,7 @@ impl Drop for EnvVarGuard {
287
287
  }
288
288
  }
289
289
 
290
+ #[cfg(unix)]
290
291
  struct TmuxShim {
291
292
  log: std::path::PathBuf,
292
293
  _path: EnvVarGuard,
@@ -297,6 +298,7 @@ struct TmuxShim {
297
298
  _real_tmux: EnvVarGuard,
298
299
  }
299
300
 
301
+ #[cfg(unix)]
300
302
  fn install_e27_tmux_shim(expected_endpoint: &str, session_name: &str) -> TmuxShim {
301
303
  use std::os::unix::fs::PermissionsExt;
302
304
 
@@ -441,6 +443,9 @@ fn assert_only_expected_socket_used(log: &std::path::Path, expected_endpoint: &s
441
443
  }
442
444
  }
443
445
 
446
+ // 0.5.x Windows portability Batch 5: E27 tests use a shell-script
447
+ // tmux shim + Unix socket paths. Unix-only.
448
+ #[cfg(unix)]
444
449
  #[test]
445
450
  #[serial_test::serial(env)]
446
451
  fn e27_stop_agent_uses_attached_explicit_state_socket() {
@@ -461,6 +466,7 @@ fn e27_stop_agent_uses_attached_explicit_state_socket() {
461
466
  );
462
467
  }
463
468
 
469
+ #[cfg(unix)]
464
470
  #[test]
465
471
  #[serial_test::serial(env)]
466
472
  fn e27_reset_agent_uses_attached_explicit_state_socket_for_stop_and_spawn() {
@@ -491,6 +497,7 @@ fn e27_reset_agent_uses_attached_explicit_state_socket_for_stop_and_spawn() {
491
497
  );
492
498
  }
493
499
 
500
+ #[cfg(unix)]
494
501
  #[test]
495
502
  #[serial_test::serial(env)]
496
503
  fn e27_stop_agent_expands_short_state_socket_name() {
@@ -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
+ }
@@ -0,0 +1,95 @@
1
+ //! Retryable OS error classifier + human-readable os_error_name.
2
+ //!
3
+ //! Batch 0 signature only. Batch 2 migrates the raw
4
+ //! `libc::{EACCES, EPERM, EBUSY, ENOSPC}` matching in
5
+ //! `state/persist.rs::retryable_replace_error` onto these helpers so
6
+ //! Windows sees the same classification via
7
+ //! `io::ErrorKind` + raw-code mapping.
8
+
9
+ use std::io;
10
+
11
+ /// True when the OS error is a transient replace failure worth
12
+ /// retrying (EACCES on Windows during antivirus scan, EBUSY on
13
+ /// mounted filesystems, etc.). Batch 2 wires the callsite.
14
+ #[allow(dead_code)]
15
+ pub fn retryable_replace_error(error: &io::Error) -> bool {
16
+ // Batch 2 will migrate `state/persist.rs::retryable_replace_error`
17
+ // here. Batch 0 provides the signature only.
18
+ #[cfg(unix)]
19
+ {
20
+ matches!(
21
+ error.raw_os_error(),
22
+ Some(c)
23
+ if c == libc::EACCES
24
+ || c == libc::EPERM
25
+ || c == libc::EBUSY
26
+ || c == libc::ENOSPC
27
+ )
28
+ }
29
+ #[cfg(not(unix))]
30
+ {
31
+ // Windows: retryable on sharing violation / access denied
32
+ // during antivirus scans. Map by `io::ErrorKind` so we don't
33
+ // depend on `windows-sys` raw codes at this layer.
34
+ matches!(
35
+ error.kind(),
36
+ io::ErrorKind::PermissionDenied | io::ErrorKind::WouldBlock
37
+ )
38
+ }
39
+ }
40
+
41
+ /// Human-readable name for a raw OS error code — used in diagnostics
42
+ /// so operators see `EACCES` instead of `13`.
43
+ #[allow(dead_code)]
44
+ pub fn os_error_name(error: &io::Error) -> Option<&'static str> {
45
+ #[cfg(unix)]
46
+ {
47
+ match error.raw_os_error()? {
48
+ c if c == libc::EACCES => Some("EACCES"),
49
+ c if c == libc::EPERM => Some("EPERM"),
50
+ c if c == libc::EBUSY => Some("EBUSY"),
51
+ c if c == libc::ENOSPC => Some("ENOSPC"),
52
+ c if c == libc::EWOULDBLOCK => Some("EWOULDBLOCK"),
53
+ c if c == libc::ESRCH => Some("ESRCH"),
54
+ _ => None,
55
+ }
56
+ }
57
+ #[cfg(not(unix))]
58
+ {
59
+ // Batch 2 will map ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION
60
+ // / ERROR_LOCK_VIOLATION here. Batch 0 leaves the mapping to
61
+ // `io::Error::kind()`.
62
+ let _ = error;
63
+ None
64
+ }
65
+ }
66
+
67
+ #[cfg(test)]
68
+ mod tests {
69
+ use super::*;
70
+
71
+ #[test]
72
+ fn retryable_replace_error_matches_eacces_on_unix() {
73
+ #[cfg(unix)]
74
+ {
75
+ let err = io::Error::from_raw_os_error(libc::EACCES);
76
+ assert!(retryable_replace_error(&err));
77
+ }
78
+ }
79
+
80
+ #[test]
81
+ fn retryable_replace_error_rejects_random_error() {
82
+ // A generic "not found" is not a replace-race condition.
83
+ let err = io::Error::from(io::ErrorKind::NotFound);
84
+ assert!(!retryable_replace_error(&err));
85
+ }
86
+
87
+ #[test]
88
+ fn os_error_name_returns_stable_wire_string_on_unix() {
89
+ #[cfg(unix)]
90
+ {
91
+ let err = io::Error::from_raw_os_error(libc::EACCES);
92
+ assert_eq!(os_error_name(&err), Some("EACCES"));
93
+ }
94
+ }
95
+ }