vibexray 0.1.0__py3-none-any.whl
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.
- vibexray/__init__.py +3 -0
- vibexray/__main__.py +3 -0
- vibexray/apprun.py +273 -0
- vibexray/apprun_boot.py +273 -0
- vibexray/apprun_crawl.py +180 -0
- vibexray/cli.py +172 -0
- vibexray/history.py +213 -0
- vibexray/model.py +162 -0
- vibexray/parts.py +95 -0
- vibexray/questions.py +269 -0
- vibexray/report/__init__.py +36 -0
- vibexray/report/assets.py +166 -0
- vibexray/report/common.py +88 -0
- vibexray/report/html.py +469 -0
- vibexray/report/markdown.py +215 -0
- vibexray/review.py +311 -0
- vibexray/rules/__init__.py +27 -0
- vibexray/rules/ai.py +636 -0
- vibexray/rules/auth.py +361 -0
- vibexray/rules/base.py +142 -0
- vibexray/rules/catalog.py +19 -0
- vibexray/rules/database.py +339 -0
- vibexray/rules/exposed_keys.py +221 -0
- vibexray/rules/fake_action.py +265 -0
- vibexray/rules/hardcoded.py +211 -0
- vibexray/rules/mock_data.py +288 -0
- vibexray/rules/security.py +442 -0
- vibexray/rules/util.py +324 -0
- vibexray/walker.py +176 -0
- vibexray-0.1.0.dist-info/METADATA +302 -0
- vibexray-0.1.0.dist-info/RECORD +34 -0
- vibexray-0.1.0.dist-info/WHEEL +4 -0
- vibexray-0.1.0.dist-info/entry_points.txt +2 -0
- vibexray-0.1.0.dist-info/licenses/LICENSE +21 -0
vibexray/__init__.py
ADDED
vibexray/__main__.py
ADDED
vibexray/apprun.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Start the prototype in a clean environment, crawl it, and always stop it again."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from vibexray.apprun_boot import (
|
|
16
|
+
USUAL_PORTS,
|
|
17
|
+
Process,
|
|
18
|
+
clean_env,
|
|
19
|
+
detect_package_manager,
|
|
20
|
+
detect_script,
|
|
21
|
+
find_env_names,
|
|
22
|
+
free_port,
|
|
23
|
+
log_tail,
|
|
24
|
+
port_open,
|
|
25
|
+
python_entry,
|
|
26
|
+
read_package,
|
|
27
|
+
reap_strays,
|
|
28
|
+
url_from_log,
|
|
29
|
+
wait_for_port_free,
|
|
30
|
+
)
|
|
31
|
+
from vibexray.apprun_crawl import RUN_EXTRA_HINT, crawl
|
|
32
|
+
from vibexray.model import AppRun
|
|
33
|
+
from vibexray.rules.base import is_env_file
|
|
34
|
+
|
|
35
|
+
BOOT_SECONDS = 90
|
|
36
|
+
COPY_SKIP = (".git", "node_modules", ".next", "dist", "build", "vibexray-report")
|
|
37
|
+
BUILD_MISSING = re.compile(r"(not found|ENOENT|Cannot find).*\b(dist|build|\.next)\b", re.I)
|
|
38
|
+
INSTALL_SECONDS = 300
|
|
39
|
+
INSTALL_ARGS = {
|
|
40
|
+
"npm": ["install", "--ignore-scripts", "--no-audit", "--no-fund"],
|
|
41
|
+
"pnpm": ["install", "--ignore-scripts"],
|
|
42
|
+
"yarn": ["install", "--ignore-scripts"],
|
|
43
|
+
"bun": ["install", "--ignore-scripts"],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _not_attempted(reason: str) -> AppRun:
|
|
48
|
+
return AppRun(state="not_attempted", reason=reason)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_app(root: Path, out_dir: Path, enabled: bool) -> AppRun:
|
|
52
|
+
if not enabled:
|
|
53
|
+
return _not_attempted("Skipped: the scan ran with --no-run.")
|
|
54
|
+
pkg = read_package(root)
|
|
55
|
+
entry = python_entry(root)
|
|
56
|
+
if pkg is None and entry is None:
|
|
57
|
+
return _not_attempted(
|
|
58
|
+
"There is no package.json or Python start file in the top folder, so vibexray "
|
|
59
|
+
"does not know how to start this app."
|
|
60
|
+
)
|
|
61
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
# The copy lives outside the user's folder, so a killed scan never leaves it there.
|
|
63
|
+
try:
|
|
64
|
+
run_dir = Path(tempfile.mkdtemp(prefix="vibexray-run-"))
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
return AppRun(
|
|
67
|
+
state="could_not_boot",
|
|
68
|
+
reason=f"vibexray could not make a temporary folder for the app ({exc.strerror}).",
|
|
69
|
+
)
|
|
70
|
+
home = run_dir / "home"
|
|
71
|
+
home.mkdir()
|
|
72
|
+
previous = _trap_stop_signals()
|
|
73
|
+
try:
|
|
74
|
+
# Install and start in a copy, so the user's folder never changes.
|
|
75
|
+
try:
|
|
76
|
+
work = _make_copy(root, run_dir / "app", out_dir)
|
|
77
|
+
except (shutil.Error, OSError) as exc:
|
|
78
|
+
return AppRun(
|
|
79
|
+
state="could_not_boot",
|
|
80
|
+
reason="vibexray could not copy the app to start it. " + _copy_problem(exc),
|
|
81
|
+
)
|
|
82
|
+
if pkg is not None:
|
|
83
|
+
script = detect_script(pkg)
|
|
84
|
+
if script is None:
|
|
85
|
+
return _not_attempted(
|
|
86
|
+
"package.json has no dev, start, or preview script, so vibexray cannot start it."
|
|
87
|
+
)
|
|
88
|
+
manager = detect_package_manager(root)
|
|
89
|
+
command = f"{manager} run {script}"
|
|
90
|
+
return _boot_and_crawl(work, out_dir, home, command, [manager, "run", script], manager)
|
|
91
|
+
cmd = ["python3", entry]
|
|
92
|
+
if entry == "manage.py":
|
|
93
|
+
cmd = ["python3", entry, "runserver"]
|
|
94
|
+
return _boot_and_crawl(work, out_dir, home, " ".join(cmd), cmd, None)
|
|
95
|
+
except PermissionError as exc:
|
|
96
|
+
return AppRun(
|
|
97
|
+
state="could_not_boot",
|
|
98
|
+
reason=(
|
|
99
|
+
"This computer blocked the app from starting "
|
|
100
|
+
f"({exc.strerror or exc}). A sandbox, such as the one in Codex, does this. "
|
|
101
|
+
"Run the scan in a normal terminal to see the app run."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
finally:
|
|
105
|
+
reap_strays(home)
|
|
106
|
+
shutil.rmtree(run_dir, ignore_errors=True)
|
|
107
|
+
_restore_signals(previous)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _trap_stop_signals() -> dict | None:
|
|
111
|
+
"""Turn SIGTERM and SIGHUP into SystemExit, so the cleanup in run_app always runs."""
|
|
112
|
+
if threading.current_thread() is not threading.main_thread():
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def stop(signum, _frame):
|
|
116
|
+
raise SystemExit(128 + signum)
|
|
117
|
+
|
|
118
|
+
return {s: signal.signal(s, stop) for s in (signal.SIGTERM, signal.SIGHUP)}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _restore_signals(previous: dict | None) -> None:
|
|
122
|
+
for sig, handler in (previous or {}).items():
|
|
123
|
+
signal.signal(sig, handler)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _copy_problem(exc: Exception) -> str:
|
|
127
|
+
# shutil.Error carries a list of (source, destination, reason) for each failed file.
|
|
128
|
+
if isinstance(exc, shutil.Error) and exc.args and isinstance(exc.args[0], list):
|
|
129
|
+
src, _, why = exc.args[0][0]
|
|
130
|
+
return f"It could not read {Path(src).name}: {str(why).split(': ')[0]}."
|
|
131
|
+
return f"{getattr(exc, 'strerror', None) or exc}."
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _make_copy(root: Path, dest: Path, out_dir: Path) -> Path:
|
|
135
|
+
skip = set(COPY_SKIP)
|
|
136
|
+
out_resolved = out_dir.resolve()
|
|
137
|
+
|
|
138
|
+
def ignore(folder: str, names: list[str]) -> set[str]:
|
|
139
|
+
left_out = set()
|
|
140
|
+
for n in names:
|
|
141
|
+
path = Path(folder) / n
|
|
142
|
+
# .env files hold the user's keys. The app starts without them.
|
|
143
|
+
if (
|
|
144
|
+
n in skip
|
|
145
|
+
or is_env_file(n)
|
|
146
|
+
or path.resolve() == out_resolved
|
|
147
|
+
or not (path.is_symlink() or path.is_dir() or path.is_file())
|
|
148
|
+
):
|
|
149
|
+
left_out.add(n)
|
|
150
|
+
return left_out
|
|
151
|
+
|
|
152
|
+
shutil.copytree(root, dest, ignore=ignore, symlinks=True)
|
|
153
|
+
if (root / "node_modules").is_dir():
|
|
154
|
+
_clone(root / "node_modules", dest / "node_modules")
|
|
155
|
+
return dest
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _clone(src: Path, dest: Path) -> None:
|
|
159
|
+
"""Copy node_modules copy-on-write, so caches the dev server writes stay in the copy.
|
|
160
|
+
|
|
161
|
+
APFS and Btrfs share the file blocks, so the clone costs almost no disk. If the clone
|
|
162
|
+
fails, the copy has no node_modules and the app run installs the packages instead.
|
|
163
|
+
"""
|
|
164
|
+
if sys.platform == "darwin":
|
|
165
|
+
cmd = ["cp", "-cR", str(src), str(dest)]
|
|
166
|
+
else:
|
|
167
|
+
cmd = ["cp", "-R", "--reflink=auto", str(src), str(dest)]
|
|
168
|
+
if subprocess.run(cmd, capture_output=True).returncode != 0:
|
|
169
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _boot_and_crawl(root, out_dir, home, command, cmd, manager) -> AppRun:
|
|
173
|
+
result = AppRun(command=command)
|
|
174
|
+
port = free_port()
|
|
175
|
+
env = clean_env(home, port)
|
|
176
|
+
tool = cmd[0]
|
|
177
|
+
if shutil.which(tool, path=env["PATH"]) is None:
|
|
178
|
+
return _failed(
|
|
179
|
+
result,
|
|
180
|
+
root,
|
|
181
|
+
f"{tool} is not installed on this computer, so the app did not start.",
|
|
182
|
+
None,
|
|
183
|
+
)
|
|
184
|
+
if manager and not (root / "node_modules").is_dir():
|
|
185
|
+
problem = _install(root, env, home, manager)
|
|
186
|
+
if problem:
|
|
187
|
+
return _failed(result, root, problem[0], problem[1])
|
|
188
|
+
# Ports that were already busy before the start belong to someone else.
|
|
189
|
+
fallback = [p for p in USUAL_PORTS if not port_open(p)]
|
|
190
|
+
server = Process(cmd, root, env, home / "server.log")
|
|
191
|
+
try:
|
|
192
|
+
url, why = _wait_for_url(server, port, fallback)
|
|
193
|
+
if url is None:
|
|
194
|
+
return _failed(result, root, why, server.log)
|
|
195
|
+
result.url = url
|
|
196
|
+
try:
|
|
197
|
+
result.pages = crawl(url, root, out_dir)
|
|
198
|
+
except ImportError:
|
|
199
|
+
return _failed(result, root, RUN_EXTRA_HINT, None, keep_url=True)
|
|
200
|
+
except Exception as exc: # noqa: BLE001 - browser launch problems must not leak a traceback
|
|
201
|
+
return _failed(
|
|
202
|
+
result,
|
|
203
|
+
root,
|
|
204
|
+
"The browser could not open the app: "
|
|
205
|
+
+ str(exc).splitlines()[0][:160]
|
|
206
|
+
+ " Run: playwright install chromium",
|
|
207
|
+
None,
|
|
208
|
+
keep_url=True,
|
|
209
|
+
)
|
|
210
|
+
result.state = "ran"
|
|
211
|
+
n = len(result.pages)
|
|
212
|
+
result.reason = f"vibexray started the app and visited {n} page{'' if n == 1 else 's'}."
|
|
213
|
+
return result
|
|
214
|
+
finally:
|
|
215
|
+
server.stop()
|
|
216
|
+
wait_for_port_free(port, 5.0)
|
|
217
|
+
if result.url:
|
|
218
|
+
wait_for_port_free(int(result.url.rsplit(":", 1)[1]), 5.0)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _install(root: Path, env: dict, home: Path, manager: str) -> tuple[str, Path] | None:
|
|
222
|
+
log = home / "install.log"
|
|
223
|
+
proc = Process([manager, *INSTALL_ARGS[manager]], root, env, log)
|
|
224
|
+
try:
|
|
225
|
+
code = proc.wait(INSTALL_SECONDS)
|
|
226
|
+
finally:
|
|
227
|
+
proc.stop()
|
|
228
|
+
if code is None:
|
|
229
|
+
return (
|
|
230
|
+
"Installing the app's packages took longer than 10 minutes, so vibexray stopped.",
|
|
231
|
+
log,
|
|
232
|
+
)
|
|
233
|
+
if code != 0:
|
|
234
|
+
return ("Installing the app's packages failed, so the app did not start.", log)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _wait_for_url(server: Process, port: int, fallback: list[int]) -> tuple[str | None, str]:
|
|
239
|
+
deadline = time.monotonic() + BOOT_SECONDS
|
|
240
|
+
while time.monotonic() < deadline:
|
|
241
|
+
if not server.alive():
|
|
242
|
+
return None, "The app started and then stopped on its own, so it cannot run here."
|
|
243
|
+
url = url_from_log(server.log)
|
|
244
|
+
if url:
|
|
245
|
+
port_in_url = int(url.rsplit(":", 1)[1])
|
|
246
|
+
if port_open(port_in_url):
|
|
247
|
+
return url, ""
|
|
248
|
+
elif port_open(port):
|
|
249
|
+
return f"http://localhost:{port}", ""
|
|
250
|
+
else:
|
|
251
|
+
for p in fallback:
|
|
252
|
+
if port_open(p):
|
|
253
|
+
return f"http://localhost:{p}", ""
|
|
254
|
+
time.sleep(0.5)
|
|
255
|
+
return None, f"The app did not answer within {BOOT_SECONDS} seconds."
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _failed(
|
|
259
|
+
result: AppRun, root: Path, reason: str, log: Path | None, keep_url: bool = False
|
|
260
|
+
) -> AppRun:
|
|
261
|
+
result.state = "could_not_boot"
|
|
262
|
+
missing = find_env_names(root)
|
|
263
|
+
result.missing_env = missing
|
|
264
|
+
tail = log_tail(log) if log is not None else []
|
|
265
|
+
if any(BUILD_MISSING.search(line) for line in tail):
|
|
266
|
+
reason += " A build step looks missing: the start file does not exist yet."
|
|
267
|
+
elif missing and log is not None:
|
|
268
|
+
reason += " It may need settings or keys that vibexray does not pass on."
|
|
269
|
+
result.reason = reason
|
|
270
|
+
result.log_tail = tail
|
|
271
|
+
if not keep_url:
|
|
272
|
+
result.url = None
|
|
273
|
+
return result
|
vibexray/apprun_boot.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Pure helpers and process control for the app run: detection, URL parsing, safe start and stop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from vibexray.rules.base import redact
|
|
17
|
+
|
|
18
|
+
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
|
19
|
+
URL = re.compile(r"https?://(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})")
|
|
20
|
+
ENV_CODE = re.compile(r"(?:process\.env|import\.meta\.env)\.([A-Za-z_][A-Za-z0-9_]*)")
|
|
21
|
+
ENV_KEY = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=", re.M)
|
|
22
|
+
SOURCE_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"}
|
|
23
|
+
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".nuxt", ".svelte-kit", "tmp"}
|
|
24
|
+
# Set by the runtime or by the clean environment itself, so never "missing".
|
|
25
|
+
BUILTIN_ENV = {"NODE_ENV", "PORT", "PATH", "HOME", "MODE", "DEV", "PROD", "SSR", "BASE_URL", "CI"}
|
|
26
|
+
USUAL_PORTS = (3000, 5173, 4173, 8080, 8000, 4321, 3001)
|
|
27
|
+
PYTHON_ENTRIES = ("app.py", "main.py", "server.py", "manage.py")
|
|
28
|
+
SCRIPT_ORDER = ("dev", "start", "preview")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_url(line: str) -> str | None:
|
|
32
|
+
"""Return the local URL a dev server prints, or None. Network lines are skipped."""
|
|
33
|
+
text = ANSI.sub("", line)
|
|
34
|
+
if "Network" in text:
|
|
35
|
+
return None
|
|
36
|
+
m = URL.search(text)
|
|
37
|
+
if not m:
|
|
38
|
+
return None
|
|
39
|
+
host = "127.0.0.1" if m.group(1) == "127.0.0.1" else "localhost"
|
|
40
|
+
return f"http://{host}:{m.group(2)}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect_package_manager(root: Path) -> str:
|
|
44
|
+
# Bun first: a repo with both bun.lock and package-lock.json usually runs its scripts with bun.
|
|
45
|
+
if (root / "bun.lock").exists() or (root / "bun.lockb").exists():
|
|
46
|
+
return "bun"
|
|
47
|
+
if (root / "pnpm-lock.yaml").exists():
|
|
48
|
+
return "pnpm"
|
|
49
|
+
if (root / "yarn.lock").exists():
|
|
50
|
+
return "yarn"
|
|
51
|
+
return "npm"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_script(pkg: dict) -> str | None:
|
|
55
|
+
scripts = pkg.get("scripts")
|
|
56
|
+
if not isinstance(scripts, dict):
|
|
57
|
+
return None
|
|
58
|
+
return next((s for s in SCRIPT_ORDER if s in scripts), None)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_package(root: Path) -> dict | None:
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
64
|
+
except (OSError, ValueError):
|
|
65
|
+
return None
|
|
66
|
+
return data if isinstance(data, dict) else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def python_entry(root: Path) -> str | None:
|
|
70
|
+
return next((e for e in PYTHON_ENTRIES if (root / e).is_file()), None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def iter_source(root: Path):
|
|
74
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
75
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
|
|
76
|
+
for name in sorted(filenames):
|
|
77
|
+
path = Path(dirpath) / name
|
|
78
|
+
if path.suffix in SOURCE_SUFFIXES:
|
|
79
|
+
yield path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_env_names(root: Path) -> list[str]:
|
|
83
|
+
names: set[str] = set()
|
|
84
|
+
for path in iter_source(root):
|
|
85
|
+
try:
|
|
86
|
+
names.update(ENV_CODE.findall(path.read_text(encoding="utf-8", errors="ignore")))
|
|
87
|
+
except OSError:
|
|
88
|
+
continue
|
|
89
|
+
for example in (".env.example", ".env.sample", ".env.template"):
|
|
90
|
+
try:
|
|
91
|
+
names.update(ENV_KEY.findall((root / example).read_text(encoding="utf-8")))
|
|
92
|
+
except OSError:
|
|
93
|
+
continue
|
|
94
|
+
return sorted(names - BUILTIN_ENV)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def clean_env(home: Path, port: int) -> dict[str, str]:
|
|
98
|
+
"""The only variables a scanned app sees. The user's own keys never pass through."""
|
|
99
|
+
return {"PATH": os.environ.get("PATH", ""), "HOME": str(home), "PORT": str(port)}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def port_open(port: int, host: str = "127.0.0.1") -> bool:
|
|
103
|
+
with socket.socket() as s:
|
|
104
|
+
s.settimeout(0.5)
|
|
105
|
+
return s.connect_ex((host, port)) == 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def free_port() -> int:
|
|
109
|
+
with socket.socket() as s:
|
|
110
|
+
s.bind(("127.0.0.1", 0))
|
|
111
|
+
return s.getsockname()[1]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def log_tail(path: Path, n: int = 20) -> list[str]:
|
|
115
|
+
try:
|
|
116
|
+
lines = ANSI.sub("", path.read_text(encoding="utf-8", errors="replace")).splitlines()
|
|
117
|
+
except OSError:
|
|
118
|
+
return []
|
|
119
|
+
return [redact(line) for line in lines if line.strip()][-n:]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def url_from_log(path: Path) -> str | None:
|
|
123
|
+
try:
|
|
124
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
125
|
+
except OSError:
|
|
126
|
+
return None
|
|
127
|
+
for line in text.splitlines():
|
|
128
|
+
url = parse_url(line)
|
|
129
|
+
if url:
|
|
130
|
+
return url
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Process:
|
|
135
|
+
"""A child in its own process group, so one call stops it and everything it spawned."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, cmd: list[str], cwd: Path, env: dict[str, str], log: Path):
|
|
138
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
self.log = log
|
|
140
|
+
self._fh = open(log, "wb") # noqa: SIM115 - closed in stop()
|
|
141
|
+
try:
|
|
142
|
+
self.proc = subprocess.Popen(
|
|
143
|
+
cmd,
|
|
144
|
+
cwd=cwd,
|
|
145
|
+
env=env,
|
|
146
|
+
stdin=subprocess.DEVNULL,
|
|
147
|
+
stdout=self._fh,
|
|
148
|
+
stderr=subprocess.STDOUT,
|
|
149
|
+
start_new_session=True,
|
|
150
|
+
)
|
|
151
|
+
except OSError:
|
|
152
|
+
self._fh.close()
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
def alive(self) -> bool:
|
|
156
|
+
return self.proc.poll() is None
|
|
157
|
+
|
|
158
|
+
def wait(self, timeout: float) -> int | None:
|
|
159
|
+
try:
|
|
160
|
+
return self.proc.wait(timeout=timeout)
|
|
161
|
+
except subprocess.TimeoutExpired:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def stop(self) -> None:
|
|
165
|
+
pgid = self.proc.pid
|
|
166
|
+
# Some tools start helpers in their own session (Next.js telemetry does). The group
|
|
167
|
+
# kill misses those, so list every descendant first and stop them by pid too.
|
|
168
|
+
kids = _descendants(pgid)
|
|
169
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
170
|
+
_signal_all(pgid, kids, sig)
|
|
171
|
+
deadline = time.monotonic() + 5.0
|
|
172
|
+
while time.monotonic() < deadline and (_group_alive(pgid) or _any_alive(kids)):
|
|
173
|
+
time.sleep(0.1)
|
|
174
|
+
if not _group_alive(pgid) and not _any_alive(kids):
|
|
175
|
+
break
|
|
176
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
177
|
+
self.proc.wait(timeout=1)
|
|
178
|
+
self._fh.close()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _ps(args: list[str]) -> str:
|
|
182
|
+
# A sandbox, such as the one Codex uses, can block ps. The process group kill still works.
|
|
183
|
+
try:
|
|
184
|
+
return subprocess.run(["ps", *args], capture_output=True, text=True).stdout
|
|
185
|
+
except OSError:
|
|
186
|
+
return ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _descendants(pid: int) -> list[int]:
|
|
190
|
+
out = _ps(["-axo", "pid=,ppid="])
|
|
191
|
+
children: dict[int, list[int]] = {}
|
|
192
|
+
for line in out.splitlines():
|
|
193
|
+
parts = line.split()
|
|
194
|
+
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
|
195
|
+
children.setdefault(int(parts[1]), []).append(int(parts[0]))
|
|
196
|
+
found, todo = [], [pid]
|
|
197
|
+
while todo:
|
|
198
|
+
for kid in children.get(todo.pop(), []):
|
|
199
|
+
found.append(kid)
|
|
200
|
+
todo.append(kid)
|
|
201
|
+
return found
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _signal_all(pgid: int, pids: list[int], sig: int) -> None:
|
|
205
|
+
if pgid > 0:
|
|
206
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
207
|
+
os.killpg(pgid, sig)
|
|
208
|
+
for pid in pids:
|
|
209
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
210
|
+
os.kill(pid, sig)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _any_alive(pids: list[int]) -> bool:
|
|
214
|
+
for pid in pids:
|
|
215
|
+
try:
|
|
216
|
+
os.kill(pid, 0)
|
|
217
|
+
except (ProcessLookupError, PermissionError):
|
|
218
|
+
continue
|
|
219
|
+
return True
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _group_alive(pgid: int) -> bool:
|
|
224
|
+
try:
|
|
225
|
+
os.killpg(pgid, 0)
|
|
226
|
+
except (ProcessLookupError, PermissionError):
|
|
227
|
+
return False
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def wait_for_port_free(port: int, seconds: float = 10.0) -> bool:
|
|
232
|
+
deadline = time.monotonic() + seconds
|
|
233
|
+
while time.monotonic() < deadline:
|
|
234
|
+
if not port_open(port):
|
|
235
|
+
return True
|
|
236
|
+
time.sleep(0.2)
|
|
237
|
+
return not port_open(port)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def reap_strays(home: Path, seconds: float = 10.0) -> None:
|
|
241
|
+
"""Stop leftovers that escaped the process group, such as the detached telemetry flush
|
|
242
|
+
that Next.js starts at exit. Only processes that carry our unique HOME in their
|
|
243
|
+
environment count, so the user's own processes are never touched."""
|
|
244
|
+
marker = f"HOME={home.resolve()}"
|
|
245
|
+
start = time.monotonic()
|
|
246
|
+
for _ in range(int(seconds / 0.2)):
|
|
247
|
+
pids = _pids_with_env(marker)
|
|
248
|
+
if not pids:
|
|
249
|
+
return
|
|
250
|
+
if time.monotonic() - start > 2.0:
|
|
251
|
+
_signal_all(-1, pids, signal.SIGKILL)
|
|
252
|
+
time.sleep(0.2)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _pids_with_env(marker: str) -> list[int]:
|
|
256
|
+
me = os.getpid()
|
|
257
|
+
if sys.platform == "darwin":
|
|
258
|
+
# -E appends each process's environment to its command line.
|
|
259
|
+
found = []
|
|
260
|
+
for line in _ps(["-axE", "-o", "pid=,command="]).splitlines():
|
|
261
|
+
pid, _, rest = line.strip().partition(" ")
|
|
262
|
+
if pid.isdigit() and int(pid) != me and f" {marker}" in f" {rest} ":
|
|
263
|
+
found.append(int(pid))
|
|
264
|
+
return found
|
|
265
|
+
found = []
|
|
266
|
+
for entry in Path("/proc").glob("[0-9]*"):
|
|
267
|
+
try:
|
|
268
|
+
env = (entry / "environ").read_bytes().split(b"\0")
|
|
269
|
+
except OSError:
|
|
270
|
+
continue
|
|
271
|
+
if marker.encode() in env and int(entry.name) != me:
|
|
272
|
+
found.append(int(entry.name))
|
|
273
|
+
return found
|