webpilot-engine 2.0.4__tar.gz → 2.0.5__tar.gz

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 (77) hide show
  1. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/PKG-INFO +1 -1
  2. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/browser_adapter.py +4 -9
  3. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/live_session_manager.py +14 -61
  4. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/builder.py +23 -18
  5. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/cli.py +22 -4
  6. webpilot_engine-2.0.5/common/process_manager.py +259 -0
  7. webpilot_engine-2.0.5/common/secrets.py +120 -0
  8. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/pyproject.toml +1 -1
  9. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/client.py +53 -53
  10. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/master_daemon.py +43 -16
  11. webpilot_engine-2.0.5/supervisor/security.py +109 -0
  12. webpilot_engine-2.0.5/tests/test_enterprise_integration.py +103 -0
  13. webpilot_engine-2.0.5/tests/test_process_manager.py +57 -0
  14. webpilot_engine-2.0.5/tests/test_secrets_resolution.py +91 -0
  15. webpilot_engine-2.0.5/tests/test_supervisor_security.py +89 -0
  16. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/PKG-INFO +1 -1
  17. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/SOURCES.txt +7 -0
  18. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/README.md +0 -0
  19. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/__init__.py +0 -0
  20. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/base.py +0 -0
  21. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/dom_scripts.py +0 -0
  22. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/envelope_builder.py +0 -0
  23. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/upload_adapter.py +0 -0
  24. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/__init__.py +0 -0
  25. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/cookies.py +0 -0
  26. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/logging.py +0 -0
  27. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/retry.py +0 -0
  28. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/text.py +0 -0
  29. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/__init__.py +0 -0
  30. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/device_profile.example.json +0 -0
  31. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/device_profile.json +0 -0
  32. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/settings.example.json +0 -0
  33. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/settings.json +0 -0
  34. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/settings.py +0 -0
  35. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/constants/__init__.py +0 -0
  36. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/constants/contracts.py +0 -0
  37. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/constants/selectors.py +0 -0
  38. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/constants/timeouts.py +0 -0
  39. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/core/__init__.py +0 -0
  40. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/core/exceptions.py +0 -0
  41. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/core/models.py +0 -0
  42. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/engine.py +0 -0
  43. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/exceptions.py +0 -0
  44. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/mcp_server.py +0 -0
  45. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/models.py +0 -0
  46. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/orchestration/__init__.py +0 -0
  47. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/orchestration/context.py +0 -0
  48. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/orchestration/flow_orchestrator.py +0 -0
  49. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/orchestration/session_coordinator.py +0 -0
  50. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/__init__.py +0 -0
  51. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/auth_navigation_service.py +0 -0
  52. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/field_interaction_service.py +0 -0
  53. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/form_filler_service.py +0 -0
  54. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/inspection_formatter_service.py +0 -0
  55. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/inspection_service.py +0 -0
  56. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/reactive_interaction_service.py +0 -0
  57. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/setup.cfg +0 -0
  58. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/__init__.py +0 -0
  59. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/contracts.py +0 -0
  60. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/shell.py +0 -0
  61. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/worker_process.py +0 -0
  62. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_config_and_builder.py +0 -0
  63. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_contracts.py +0 -0
  64. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_engine_facade.py +0 -0
  65. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_live_session.py +0 -0
  66. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_mcp_server.py +0 -0
  67. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_orchestration.py +0 -0
  68. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_redaction.py +0 -0
  69. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_services.py +0 -0
  70. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_supervisor.py +0 -0
  71. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/validators/__init__.py +0 -0
  72. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/validators/form_validator.py +0 -0
  73. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/validators/payload_validator.py +0 -0
  74. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/dependency_links.txt +0 -0
  75. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/entry_points.txt +0 -0
  76. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/requires.txt +0 -0
  77. {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webpilot-engine
3
- Version: 2.0.4
3
+ Version: 2.0.5
4
4
  Summary: WebPilot: Enterprise Autonomous Browser Engine, Universal Form Inspector & AI Agent Web Runtime
5
5
  Author: Abdulkarim Salih, WebPilot Core Team
6
6
  License: MIT
@@ -253,15 +253,10 @@ class BrowserAdapter(BrowserAdapterProtocol):
253
253
  args=["--disable-blink-features=AutomationControlled"],
254
254
  )
255
255
  try:
256
- if sys.platform == "win32":
257
- ps_c = f'(Get-CimInstance Win32_Process | Where-Object {{ $_.ParentProcessId -eq {os.getpid()} }}).ProcessId | Select-Object -First 1'
258
- out = subprocess.check_output(["powershell", "-NoProfile", "-Command", ps_c], text=True).strip()
259
- if out.isdigit():
260
- self.browser_pid = int(out)
261
- else:
262
- out = subprocess.check_output(["pgrep", "-P", str(os.getpid())], text=True).strip().split()
263
- if out and out[0].isdigit():
264
- self.browser_pid = int(out[0])
256
+ from common.process_manager import get_process_manager
257
+ children = get_process_manager().get_child_pids(os.getpid())
258
+ if children:
259
+ self.browser_pid = children[0]
265
260
  except Exception:
266
261
  pass
267
262
 
@@ -124,16 +124,8 @@ class LiveSessionManager:
124
124
  pid = state.get("pid")
125
125
  if pid:
126
126
  try:
127
- # Terminate on Windows
128
- if sys.platform == "win32":
129
- subprocess.run(
130
- ["taskkill", "/F", "/T", "/PID", str(pid)],
131
- stdout=subprocess.DEVNULL,
132
- stderr=subprocess.DEVNULL,
133
- )
134
- else:
135
- os.kill(pid, 9)
136
- return True
127
+ from common.process_manager import get_process_manager
128
+ return get_process_manager().kill_process_tree(pid, force=True)
137
129
  except Exception:
138
130
  pass
139
131
  return False
@@ -267,62 +259,23 @@ class LiveSessionManager:
267
259
  @classmethod
268
260
  def _launch_wmi_process(cls, cmd_str: str, cwd: Optional[str] = None) -> Optional[int]:
269
261
  """Launches a detached process via Windows WMI outside any parent Job Object."""
262
+ from common.process_manager import get_process_manager
270
263
  working_dir = cwd or PROJECT_ROOT
271
- ps_script = f"""
272
- $startup = ([wmiclass]"Win32_ProcessStartup").CreateInstance()
273
- $startup.ShowWindow = 0
274
- $proc = ([wmiclass]"Win32_Process").Create(@'
275
- {cmd_str}
276
- '@, $null, $startup)
277
- if ($proc.ReturnValue -eq 0) {{
278
- Write-Output $proc.ProcessId
279
- }} else {{
280
- exit 1
281
- }}
282
- """
283
- try:
284
- encoded = base64.b64encode(ps_script.encode("utf-16le")).decode("ascii")
285
- res = subprocess.run(
286
- ["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
287
- capture_output=True,
288
- text=True,
289
- timeout=10,
290
- )
291
- out = res.stdout.strip()
292
- if res.returncode == 0 and out.isdigit():
293
- return int(out)
294
- else:
295
- print(f"[!] WMI launch non-zero/non-digit: code={res.returncode}, out='{out}', err='{res.stderr.strip()}'")
296
- except Exception as exc:
297
- print(f"[!] WMI launch exception: {exc}")
298
- return None
264
+ return get_process_manager().launch_detached(cmd_str, cwd=working_dir)
299
265
 
300
266
  @classmethod
301
267
  def _spawn_watchdog_supervisor(cls, browser_pid: int, port: int, timeout_seconds: int) -> None:
302
268
  """Launches a lightweight detached background watchdog to terminate idle browser."""
303
- watchdog_cmd = f'"{sys.executable}" -m adapters.live_session_manager {browser_pid} {port} {timeout_seconds}'
304
-
305
- if sys.platform == "win32":
306
- wmi_pid = cls._launch_wmi_process(watchdog_cmd)
307
- if wmi_pid is not None:
308
- return
309
-
310
- popen_kwargs: Dict[str, Any] = {
311
- "stdout": subprocess.DEVNULL,
312
- "stderr": subprocess.DEVNULL,
313
- }
314
- if sys.platform == "win32":
315
- popen_kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
316
- else:
317
- popen_kwargs["start_new_session"] = True
318
-
319
- try:
320
- subprocess.Popen(
321
- [sys.executable, "-m", "adapters.live_session_manager", str(browser_pid), str(port), str(timeout_seconds)],
322
- **popen_kwargs,
323
- )
324
- except Exception:
325
- pass
269
+ from common.process_manager import get_process_manager
270
+ watchdog_cmd = [
271
+ sys.executable,
272
+ "-m",
273
+ "adapters.live_session_manager",
274
+ str(browser_pid),
275
+ str(port),
276
+ str(timeout_seconds),
277
+ ]
278
+ get_process_manager().launch_detached(watchdog_cmd, cwd=PROJECT_ROOT)
326
279
 
327
280
 
328
281
  if __name__ == "__main__":
@@ -34,7 +34,17 @@ class FormPayloadBuilder:
34
34
 
35
35
  @staticmethod
36
36
  def load_from_file(filepath: str) -> List[Tuple[str, Any]]:
37
- """Load field key-value pairs from an external JSON file."""
37
+ """Load field key-value pairs from an external JSON file or stdin ('-' or '@stdin')."""
38
+ import sys
39
+ from common.secrets import resolve_secret_value
40
+
41
+ if filepath in ("-", "@stdin"):
42
+ try:
43
+ raw_data = json.load(sys.stdin)
44
+ except Exception as exc:
45
+ raise ValidationError(f"Invalid JSON read from standard input: {exc}") from exc
46
+ return FormPayloadBuilder.normalize_payload(raw_data)
47
+
38
48
  if not os.path.isfile(filepath):
39
49
  raise ConfigurationError(f"Form payload data file not found: {filepath}")
40
50
 
@@ -48,18 +58,21 @@ class FormPayloadBuilder:
48
58
 
49
59
  @staticmethod
50
60
  def normalize_payload(raw_data: Union[Dict[str, Any], List[Any]]) -> List[Tuple[str, Any]]:
51
- """Normalizes dict or list structures into a clean list of (field, value) pairs."""
61
+ """Normalizes dict or list structures into a clean list of (field, value) pairs and resolves secrets."""
62
+ from common.secrets import resolve_secret_value
63
+
52
64
  fields: List[Tuple[str, Any]] = []
53
65
 
54
66
  if isinstance(raw_data, dict):
55
67
  for k, v in raw_data.items():
56
- fields.append((str(k), v))
68
+ fields.append((str(k), resolve_secret_value(v, prompt_label=str(k))))
57
69
  elif isinstance(raw_data, list):
58
70
  for item in raw_data:
59
71
  if isinstance(item, (list, tuple)) and len(item) == 2:
60
- fields.append((str(item[0]), item[1]))
72
+ fields.append((str(item[0]), resolve_secret_value(item[1], prompt_label=str(item[0]))))
61
73
  elif isinstance(item, dict) and "key" in item and "value" in item:
62
- fields.append((str(item["key"]), item["value"]))
74
+ k = str(item["key"])
75
+ fields.append((k, resolve_secret_value(item["value"], prompt_label=k)))
63
76
  else:
64
77
  raise ValidationError(f"Unrecognized payload entry structure: {item}")
65
78
  else:
@@ -68,16 +81,8 @@ class FormPayloadBuilder:
68
81
  return fields
69
82
 
70
83
  @staticmethod
71
- def parse_cli_fill_arguments(fill_args: Optional[List[str]]) -> List[Tuple[str, str]]:
72
- """Parses CLI --fill 'key=value' arguments into key-value pairs."""
73
- if not fill_args:
74
- return []
75
-
76
- parsed: List[Tuple[str, str]] = []
77
- for item in fill_args:
78
- if "=" in item:
79
- k, v = item.split("=", 1)
80
- parsed.append((k.strip(), v.strip()))
81
- else:
82
- raise ValidationError(f"Invalid fill argument format: '{item}' (Expected 'key=value')")
83
- return parsed
84
+ def parse_cli_fill_arguments(fill_args: Optional[List[str]]) -> List[Tuple[str, Any]]:
85
+ """Parses CLI --fill arguments into key-value pairs and resolves secrets (@env, @stdin, @file)."""
86
+ from common.secrets import parse_and_resolve_fill_args
87
+ return parse_and_resolve_fill_args(fill_args)
88
+
@@ -116,11 +116,29 @@ def apply_cmd(args: argparse.Namespace) -> None:
116
116
  """Executes the universal form filling and submission workflow."""
117
117
  auto_inspect = not getattr(args, "no_auto_inspect", False)
118
118
 
119
+ from common.secrets import parse_and_resolve_fill_args
120
+ from builder import FormPayloadBuilder
121
+
122
+ raw_fill_args = args.fill or []
123
+ resolved_pairs = parse_and_resolve_fill_args(raw_fill_args)
124
+ effective_fill_args = [f"{k}={v}" for k, v in resolved_pairs]
125
+
126
+ effective_data_path = args.data
127
+ if args.data in ("-", "@stdin"):
128
+ try:
129
+ stdin_json = json.load(sys.stdin)
130
+ stdin_pairs = FormPayloadBuilder.normalize_payload(stdin_json)
131
+ effective_fill_args.extend([f"{k}={v}" for k, v in stdin_pairs])
132
+ effective_data_path = None
133
+ except Exception as exc:
134
+ print(f"[!] Error reading JSON payload from stdin: {exc}", file=sys.stderr)
135
+ sys.exit(1)
136
+
119
137
  if getattr(args, "standalone", False):
120
138
  req = ApplyRequestContext(
121
139
  url=args.url,
122
- data_path=args.data,
123
- fill_arguments=args.fill or [],
140
+ data_path=effective_data_path,
141
+ fill_arguments=effective_fill_args,
124
142
  press_buttons=args.press or [],
125
143
  upload_files=args.upload or [],
126
144
  cookies_path=args.cookies,
@@ -165,8 +183,8 @@ def apply_cmd(args: argparse.Namespace) -> None:
165
183
  req = SupervisorActionRequest(
166
184
  action="apply",
167
185
  url=args.url,
168
- data_path=args.data,
169
- fill_arguments=args.fill or [],
186
+ data_path=effective_data_path,
187
+ fill_arguments=effective_fill_args,
170
188
  press_buttons=args.press or [],
171
189
  upload_files=args.upload or [],
172
190
  cookies_path=args.cookies,
@@ -0,0 +1,259 @@
1
+ """Cross-Platform Process Management and Isolation Layer.
2
+
3
+ Isolates OS-specific process lifecycle mechanisms (WMI, CIM, taskkill on Windows;
4
+ killpg, pgrep, signals on POSIX/Linux/macOS) behind a clean, unified ProcessManager interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import os
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ from abc import ABC, abstractmethod
15
+ from typing import Any, Dict, List, Optional, Union
16
+
17
+
18
+ class BaseProcessManager(ABC):
19
+ """Abstract interface for system process management and detached lifecycle execution."""
20
+
21
+ @abstractmethod
22
+ def launch_detached(
23
+ self,
24
+ cmd: Union[str, List[str]],
25
+ cwd: Optional[str] = None,
26
+ env: Optional[Dict[str, str]] = None,
27
+ ) -> Optional[int]:
28
+ """Launches a detached process decoupled from the caller's terminal and process group."""
29
+ pass
30
+
31
+ @abstractmethod
32
+ def kill_process_tree(self, pid: int, force: bool = True) -> bool:
33
+ """Kills the target process and all descendant child processes (cascading teardown)."""
34
+ pass
35
+
36
+ @abstractmethod
37
+ def get_child_pids(self, parent_pid: int) -> List[int]:
38
+ """Returns direct child process IDs for the given parent PID."""
39
+ pass
40
+
41
+ @abstractmethod
42
+ def is_process_running(self, pid: int) -> bool:
43
+ """Checks if a process with the specified PID is currently active."""
44
+ pass
45
+
46
+
47
+ class WindowsProcessManager(BaseProcessManager):
48
+ """Windows-specific implementation using PowerShell WMI/CIM and taskkill."""
49
+
50
+ def launch_detached(
51
+ self,
52
+ cmd: Union[str, List[str]],
53
+ cwd: Optional[str] = None,
54
+ env: Optional[Dict[str, str]] = None,
55
+ ) -> Optional[int]:
56
+ target_cwd = cwd or os.getcwd()
57
+ if isinstance(cmd, list):
58
+ cmd_str = " ".join(f'"{arg}"' if " " in arg and not arg.startswith('"') else arg for arg in cmd)
59
+ else:
60
+ cmd_str = cmd
61
+
62
+ # Primary approach: Win32_Process.Create via WMI to break out of terminal Job Objects
63
+ ps_script = f"""
64
+ $cwd = '{target_cwd}'
65
+ $cmd = @'
66
+ {cmd_str}
67
+ '@
68
+ $startup = ([wmiclass]'Win32_ProcessStartup').CreateInstance()
69
+ $startup.ShowWindow = 0
70
+ $proc = ([wmiclass]'Win32_Process').Create($cmd, $cwd, $startup)
71
+ if ($proc.ReturnValue -eq 0) {{
72
+ Write-Output $proc.ProcessId
73
+ }} else {{
74
+ exit 1
75
+ }}
76
+ """
77
+ try:
78
+ encoded = base64.b64encode(ps_script.encode("utf-16le")).decode("ascii")
79
+ res = subprocess.run(
80
+ ["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
81
+ capture_output=True,
82
+ text=True,
83
+ timeout=10,
84
+ )
85
+ out = res.stdout.strip()
86
+ if res.returncode == 0 and out.isdigit():
87
+ return int(out)
88
+ except Exception:
89
+ pass
90
+
91
+ # Fallback approach: subprocess.Popen with DETACHED_PROCESS
92
+ try:
93
+ cmd_args = cmd if isinstance(cmd, list) else cmd_str
94
+ creation_flags = (
95
+ getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
96
+ | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
97
+ )
98
+ proc = subprocess.Popen(
99
+ cmd_args,
100
+ cwd=target_cwd,
101
+ env=env,
102
+ stdout=subprocess.DEVNULL,
103
+ stderr=subprocess.DEVNULL,
104
+ stdin=subprocess.DEVNULL,
105
+ creationflags=creation_flags,
106
+ )
107
+ return proc.pid
108
+ except Exception:
109
+ return None
110
+
111
+ def kill_process_tree(self, pid: int, force: bool = True) -> bool:
112
+ if not pid or pid <= 0:
113
+ return False
114
+ args = ["taskkill"]
115
+ if force:
116
+ args.append("/F")
117
+ args.extend(["/T", "/PID", str(pid)])
118
+ try:
119
+ res = subprocess.run(
120
+ args,
121
+ stdout=subprocess.DEVNULL,
122
+ stderr=subprocess.DEVNULL,
123
+ timeout=10,
124
+ )
125
+ return res.returncode == 0
126
+ except Exception:
127
+ return False
128
+
129
+ def get_child_pids(self, parent_pid: int) -> List[int]:
130
+ if not parent_pid or parent_pid <= 0:
131
+ return []
132
+ try:
133
+ ps_c = f"(Get-CimInstance Win32_Process | Where-Object {{ $_.ParentProcessId -eq {parent_pid} }}).ProcessId"
134
+ out = subprocess.check_output(
135
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_c],
136
+ text=True,
137
+ stderr=subprocess.DEVNULL,
138
+ timeout=8,
139
+ ).strip()
140
+ pids = []
141
+ for line in out.splitlines():
142
+ line = line.strip()
143
+ if line.isdigit():
144
+ pids.append(int(line))
145
+ return pids
146
+ except Exception:
147
+ return []
148
+
149
+ def is_process_running(self, pid: int) -> bool:
150
+ if not pid or pid <= 0:
151
+ return False
152
+ try:
153
+ ps_c = f"(Get-Process -Id {pid} -ErrorAction SilentlyContinue) -ne $null"
154
+ out = subprocess.check_output(
155
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_c],
156
+ text=True,
157
+ stderr=subprocess.DEVNULL,
158
+ timeout=5,
159
+ ).strip()
160
+ return out.lower() == "true"
161
+ except Exception:
162
+ return False
163
+
164
+
165
+ class PosixProcessManager(BaseProcessManager):
166
+ """POSIX/Linux/macOS implementation using setsid, signal.SIGKILL, and pgrep."""
167
+
168
+ def launch_detached(
169
+ self,
170
+ cmd: Union[str, List[str]],
171
+ cwd: Optional[str] = None,
172
+ env: Optional[Dict[str, str]] = None,
173
+ ) -> Optional[int]:
174
+ target_cwd = cwd or os.getcwd()
175
+ try:
176
+ cmd_args = cmd if isinstance(cmd, list) else cmd.split()
177
+ proc = subprocess.Popen(
178
+ cmd_args,
179
+ cwd=target_cwd,
180
+ env=env,
181
+ stdout=subprocess.DEVNULL,
182
+ stderr=subprocess.DEVNULL,
183
+ stdin=subprocess.DEVNULL,
184
+ start_new_session=True, # setsid decoupling
185
+ )
186
+ return proc.pid
187
+ except Exception:
188
+ return None
189
+
190
+ def kill_process_tree(self, pid: int, force: bool = True) -> bool:
191
+ if not pid or pid <= 0:
192
+ return False
193
+ sig = signal.SIGKILL if force else signal.SIGTERM
194
+ killed = False
195
+ # Strategy 1: Process group kill if leader
196
+ try:
197
+ pgid = os.getpgid(pid)
198
+ os.killpg(pgid, sig)
199
+ killed = True
200
+ except Exception:
201
+ pass
202
+
203
+ # Strategy 2: pkill children then kill target
204
+ try:
205
+ subprocess.run(["pkill", "-P", str(pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
206
+ os.kill(pid, sig)
207
+ killed = True
208
+ except Exception:
209
+ pass
210
+
211
+ return killed
212
+
213
+ def get_child_pids(self, parent_pid: int) -> List[int]:
214
+ if not parent_pid or parent_pid <= 0:
215
+ return []
216
+ try:
217
+ out = subprocess.check_output(
218
+ ["pgrep", "-P", str(parent_pid)],
219
+ text=True,
220
+ stderr=subprocess.DEVNULL,
221
+ timeout=5,
222
+ ).strip()
223
+ pids = []
224
+ for line in out.splitlines():
225
+ line = line.strip()
226
+ if line.isdigit():
227
+ pids.append(int(line))
228
+ return pids
229
+ except Exception:
230
+ return []
231
+
232
+ def is_process_running(self, pid: int) -> bool:
233
+ if not pid or pid <= 0:
234
+ return False
235
+ try:
236
+ os.kill(pid, 0)
237
+ return True
238
+ except OSError:
239
+ return False
240
+
241
+
242
+ _GLOBAL_PROCESS_MANAGER: Optional[BaseProcessManager] = None
243
+
244
+
245
+ def get_process_manager() -> BaseProcessManager:
246
+ """Returns the platform-appropriate ProcessManager singleton."""
247
+ global _GLOBAL_PROCESS_MANAGER
248
+ if _GLOBAL_PROCESS_MANAGER is None:
249
+ if sys.platform == "win32":
250
+ _GLOBAL_PROCESS_MANAGER = WindowsProcessManager()
251
+ else:
252
+ _GLOBAL_PROCESS_MANAGER = PosixProcessManager()
253
+ return _GLOBAL_PROCESS_MANAGER
254
+
255
+
256
+ def set_process_manager(manager: Optional[BaseProcessManager]) -> None:
257
+ """Allows injecting custom or mock ProcessManager implementations for testing."""
258
+ global _GLOBAL_PROCESS_MANAGER
259
+ _GLOBAL_PROCESS_MANAGER = manager
@@ -0,0 +1,120 @@
1
+ """Secrets resolution and credential hygiene utilities.
2
+
3
+ Enables secure injection of sensitive form values without leaving plain-text
4
+ passwords in terminal history, process lists, or shell logfiles.
5
+ Supports:
6
+ - @env:VARIABLE_NAME (reads secret from specified environment variable)
7
+ - @stdin (reads securely from stdin/pipe without shell echo)
8
+ - @file:FILE_PATH (reads secret from a local protected file)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import getpass
14
+ import os
15
+ import sys
16
+ from typing import Any, Dict, List, Optional, Tuple, Union
17
+
18
+ from core.exceptions import ConfigurationError, ValidationError
19
+
20
+
21
+ def resolve_secret_value(value: Any, prompt_label: Optional[str] = None) -> Any:
22
+ """Resolves secret references (@env:VAR, @stdin, @file:PATH) to actual string values."""
23
+ if not isinstance(value, str):
24
+ return value
25
+
26
+ trimmed = value.strip()
27
+
28
+ # 1. Environment Variable Reference (@env:VAR_NAME)
29
+ if trimmed.startswith("@env:"):
30
+ var_name = trimmed[5:].strip()
31
+ if not var_name:
32
+ raise ValidationError("Empty environment variable name specified in '@env:' reference.")
33
+ env_val = os.environ.get(var_name)
34
+ if env_val is None:
35
+ raise ConfigurationError(
36
+ f"Referenced environment variable '{var_name}' is not set. "
37
+ f"Please define '{var_name}' before invoking WebPilot."
38
+ )
39
+ return env_val
40
+
41
+ # 2. Standard Input Reference (@stdin or pure '-')
42
+ if trimmed in ("@stdin", "-"):
43
+ if sys.stdin.isatty():
44
+ # Interactive terminal: prompt securely without terminal echo
45
+ label = prompt_label or "secret value"
46
+ try:
47
+ entered = getpass.getpass(f"Enter {label}: ")
48
+ return entered
49
+ except Exception as exc:
50
+ raise ValidationError(f"Failed to read secret from stdin: {exc}") from exc
51
+ else:
52
+ # Piped or redirected input: read single line from pipe
53
+ line = sys.stdin.readline()
54
+ return line.rstrip("\r\n")
55
+
56
+ # 3. Secure File Reference (@file:PATH)
57
+ if trimmed.startswith("@file:"):
58
+ file_path = trimmed[6:].strip()
59
+ if not os.path.isfile(file_path):
60
+ raise ConfigurationError(f"Referenced secret file not found: {file_path}")
61
+ try:
62
+ with open(file_path, "r", encoding="utf-8") as f:
63
+ return f.read().rstrip("\r\n")
64
+ except Exception as exc:
65
+ raise ConfigurationError(f"Failed to read secret file '{file_path}': {exc}") from exc
66
+
67
+ return value
68
+
69
+
70
+ def resolve_payload_secrets(data: Any) -> Any:
71
+ """Recursively resolves secret references in arbitrary dictionary or list structures."""
72
+ if isinstance(data, dict):
73
+ return {k: resolve_payload_secrets(v) for k, v in data.items()}
74
+ elif isinstance(data, list):
75
+ return [resolve_payload_secrets(item) for item in data]
76
+ elif isinstance(data, tuple) and len(data) == 2:
77
+ return (data[0], resolve_secret_value(data[1], prompt_label=str(data[0])))
78
+ elif isinstance(data, str):
79
+ return resolve_secret_value(data)
80
+ return data
81
+
82
+
83
+ def parse_and_resolve_fill_args(fill_args: Optional[List[str]]) -> List[Tuple[str, Any]]:
84
+ """Parses list of CLI --fill arguments and resolves any @env, @stdin, or @file references.
85
+
86
+ Also handles batch input from stdin if an argument is literally '-' or '@stdin'.
87
+ """
88
+ if not fill_args:
89
+ return []
90
+
91
+ results: List[Tuple[str, Any]] = []
92
+
93
+ for item in fill_args:
94
+ trimmed = item.strip()
95
+
96
+ # Check for batch stdin ingestion
97
+ if trimmed in ("-", "@stdin"):
98
+ if not sys.stdin.isatty():
99
+ # Read all lines from pipe: each line expected to be key=value
100
+ for line in sys.stdin:
101
+ line_str = line.strip()
102
+ if line_str and "=" in line_str and not line_str.startswith("#"):
103
+ k, v = line_str.split("=", 1)
104
+ resolved_v = resolve_secret_value(v.strip(), prompt_label=k.strip())
105
+ results.append((k.strip(), resolved_v))
106
+ continue
107
+
108
+ if "=" not in trimmed:
109
+ raise ValidationError(
110
+ f"Invalid --fill argument format: '{item}'. "
111
+ "Expected 'key=value', e.g. --fill 'username=john' or --fill 'password=@env:MY_PASS'"
112
+ )
113
+
114
+ k, v = trimmed.split("=", 1)
115
+ key = k.strip()
116
+ raw_val = v.strip()
117
+ resolved_val = resolve_secret_value(raw_val, prompt_label=key)
118
+ results.append((key, resolved_val))
119
+
120
+ return results
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "webpilot-engine"
7
- version = "2.0.4"
7
+ version = "2.0.5"
8
8
  description = "WebPilot: Enterprise Autonomous Browser Engine, Universal Form Inspector & AI Agent Web Runtime"
9
9
  readme = "README.md"
10
10
  authors = [