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.
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/PKG-INFO +1 -1
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/browser_adapter.py +4 -9
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/live_session_manager.py +14 -61
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/builder.py +23 -18
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/cli.py +22 -4
- webpilot_engine-2.0.5/common/process_manager.py +259 -0
- webpilot_engine-2.0.5/common/secrets.py +120 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/pyproject.toml +1 -1
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/client.py +53 -53
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/master_daemon.py +43 -16
- webpilot_engine-2.0.5/supervisor/security.py +109 -0
- webpilot_engine-2.0.5/tests/test_enterprise_integration.py +103 -0
- webpilot_engine-2.0.5/tests/test_process_manager.py +57 -0
- webpilot_engine-2.0.5/tests/test_secrets_resolution.py +91 -0
- webpilot_engine-2.0.5/tests/test_supervisor_security.py +89 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/PKG-INFO +1 -1
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/SOURCES.txt +7 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/README.md +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/__init__.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/base.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/dom_scripts.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/envelope_builder.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/adapters/upload_adapter.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/__init__.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/cookies.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/logging.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/retry.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/common/text.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/__init__.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/device_profile.example.json +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/device_profile.json +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/settings.example.json +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/settings.json +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/config/settings.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/constants/__init__.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/constants/contracts.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/constants/selectors.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/constants/timeouts.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/core/__init__.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/core/exceptions.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/core/models.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/engine.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/exceptions.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/mcp_server.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/models.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/orchestration/__init__.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/orchestration/context.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/orchestration/flow_orchestrator.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/orchestration/session_coordinator.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/__init__.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/auth_navigation_service.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/field_interaction_service.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/form_filler_service.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/inspection_formatter_service.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/inspection_service.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/services/reactive_interaction_service.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/setup.cfg +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/__init__.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/contracts.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/shell.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/supervisor/worker_process.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_config_and_builder.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_contracts.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_engine_facade.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_live_session.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_mcp_server.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_orchestration.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_redaction.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_services.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/tests/test_supervisor.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/validators/__init__.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/validators/form_validator.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/validators/payload_validator.py +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/dependency_links.txt +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/entry_points.txt +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/requires.txt +0 -0
- {webpilot_engine-2.0.4 → webpilot_engine-2.0.5}/webpilot_engine.egg-info/top_level.txt +0 -0
|
@@ -253,15 +253,10 @@ class BrowserAdapter(BrowserAdapterProtocol):
|
|
|
253
253
|
args=["--disable-blink-features=AutomationControlled"],
|
|
254
254
|
)
|
|
255
255
|
try:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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,
|
|
72
|
-
"""Parses CLI --fill
|
|
73
|
-
|
|
74
|
-
|
|
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=
|
|
123
|
-
fill_arguments=
|
|
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=
|
|
169
|
-
fill_arguments=
|
|
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.
|
|
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 = [
|