ai-cli-toolkit 0.2.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.
ai_cli/proxy.py ADDED
@@ -0,0 +1,627 @@
1
+ """mitmdump lifecycle management: resolve, install, start, stop.
2
+
3
+ Handles finding or auto-installing mitmdump, building the command with
4
+ addon scripts, starting the proxy process, and setting up environment
5
+ variables for the wrapped CLI tool.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import http.client
11
+ import http.server
12
+ import os
13
+ import shlex
14
+ import shutil
15
+ import socket
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from collections.abc import MutableMapping
23
+
24
+ from ai_cli.log import append_log, fmt_cmd, tail_file, tail_text
25
+
26
+ PINNED_MITM_ENV = "AI_CLI_PINNED_MITM_BIN"
27
+ PINNED_MITM_DIR = Path.home() / ".ai-cli" / "bin"
28
+ PINNED_MITMDUMP = PINNED_MITM_DIR / "mitmdump"
29
+
30
+
31
+ def _realpath_str(path: str) -> str:
32
+ return os.path.realpath(os.path.expanduser(path))
33
+
34
+
35
+ def _is_pinned_mitmdump_path(path: str) -> bool:
36
+ return _realpath_str(path) == _realpath_str(str(PINNED_MITMDUMP))
37
+
38
+
39
+ def _read_pinned_wrapper_target(wrapper_path: Path) -> str | None:
40
+ """Return the wrapped target from a pinned mitmdump wrapper, if present."""
41
+ try:
42
+ text = wrapper_path.read_text(encoding="utf-8")
43
+ except OSError:
44
+ return None
45
+
46
+ for raw in text.splitlines():
47
+ line = raw.strip()
48
+ if not line.startswith("exec "):
49
+ continue
50
+ try:
51
+ parts = shlex.split(line)
52
+ except ValueError:
53
+ continue
54
+ if len(parts) < 2:
55
+ continue
56
+ target = _realpath_str(parts[1])
57
+ if target == _realpath_str(str(wrapper_path)):
58
+ continue
59
+ return target
60
+ return None
61
+
62
+
63
+ def _prepend_path_dir(
64
+ path_dir: str, env: MutableMapping[str, str] | None = None
65
+ ) -> MutableMapping[str, str]:
66
+ """Prepend *path_dir* to PATH in *env* (or os.environ), deduplicated."""
67
+ target = os.environ if env is None else env
68
+ current = target.get("PATH", "")
69
+ parts = [part for part in current.split(os.pathsep) if part]
70
+ parts = [part for part in parts if part != path_dir]
71
+ parts.insert(0, path_dir)
72
+ target["PATH"] = os.pathsep.join(parts)
73
+ return target
74
+
75
+
76
+ def _prepend_user_bin_dirs() -> None:
77
+ """Ensure common user-local bin directories are on PATH."""
78
+ py_major = sys.version_info.major
79
+ py_minor = sys.version_info.minor
80
+ candidates = [
81
+ Path.home() / ".local/bin",
82
+ Path.home() / f"Library/Python/{py_major}.{py_minor}/bin",
83
+ ]
84
+ for candidate in candidates:
85
+ _prepend_path_dir(str(candidate))
86
+
87
+
88
+ def _pin_mitmdump_binary(binary: str, log_path: Path | None = None) -> str:
89
+ """Pin mitmdump to ~/.ai-cli/bin/mitmdump and prepend that dir to PATH."""
90
+ resolved = _realpath_str(binary)
91
+ pinned = str(PINNED_MITMDUMP)
92
+ pinned_real = _realpath_str(pinned)
93
+
94
+ # If caller passes the pinned wrapper path, unwrap to the real target so we
95
+ # never generate an exec-to-self script.
96
+ if resolved == pinned_real:
97
+ target = _read_pinned_wrapper_target(PINNED_MITMDUMP)
98
+ if target:
99
+ resolved = target
100
+ elif log_path is not None:
101
+ append_log(
102
+ log_path,
103
+ "Warning: pinned mitmdump wrapper target could not be resolved; "
104
+ "keeping existing wrapper unchanged.",
105
+ )
106
+
107
+ if resolved != pinned_real:
108
+ try:
109
+ PINNED_MITM_DIR.mkdir(parents=True, exist_ok=True)
110
+ wrapper = (
111
+ "#!/usr/bin/env bash\n"
112
+ f"exec {shlex.quote(resolved)} \"$@\"\n"
113
+ )
114
+ PINNED_MITMDUMP.write_text(wrapper, encoding="utf-8")
115
+ PINNED_MITMDUMP.chmod(0o755)
116
+ selected = pinned
117
+ except OSError as exc:
118
+ selected = resolved
119
+ if log_path is not None:
120
+ append_log(
121
+ log_path,
122
+ f"Warning: failed to pin mitmdump at {pinned}: {exc}. "
123
+ f"Using resolved binary {resolved}.",
124
+ )
125
+ else:
126
+ selected = pinned
127
+
128
+ os.environ[PINNED_MITM_ENV] = selected
129
+ os.environ["MITM_BIN"] = selected
130
+ _prepend_path_dir(str(Path(selected).parent))
131
+ return selected
132
+
133
+
134
+ def apply_pinned_mitmdump_path(env: dict[str, str]) -> dict[str, str]:
135
+ """Ensure *env* resolves mitmdump to the pinned binary first."""
136
+ pinned = (
137
+ env.get(PINNED_MITM_ENV, "").strip()
138
+ or os.environ.get(PINNED_MITM_ENV, "").strip()
139
+ or env.get("MITM_BIN", "").strip()
140
+ or os.environ.get("MITM_BIN", "").strip()
141
+ )
142
+ if not pinned:
143
+ return env
144
+ resolved = os.path.realpath(os.path.expanduser(pinned))
145
+ env[PINNED_MITM_ENV] = resolved
146
+ env["MITM_BIN"] = resolved
147
+ _prepend_path_dir(str(Path(resolved).parent), env=env)
148
+ return env
149
+
150
+
151
+ def _iter_path_executables(binary_name: str) -> list[str]:
152
+ """Return executable matches for *binary_name* across PATH in order."""
153
+ path_value = os.environ.get("PATH", "")
154
+ if not path_value:
155
+ return []
156
+
157
+ matches: list[str] = []
158
+ for raw_dir in path_value.split(os.pathsep):
159
+ if not raw_dir:
160
+ continue
161
+ candidate = Path(raw_dir) / binary_name
162
+ if not candidate.is_file():
163
+ continue
164
+ if not os.access(candidate, os.X_OK):
165
+ continue
166
+ matches.append(str(candidate))
167
+ return matches
168
+
169
+
170
+ def _probe_mitmdump(binary: str) -> tuple[bool, str]:
171
+ """Run a lightweight health-check against a mitmdump binary."""
172
+ try:
173
+ result = subprocess.run(
174
+ [binary, "--version"],
175
+ check=False,
176
+ capture_output=True,
177
+ text=True,
178
+ timeout=5,
179
+ )
180
+ except (OSError, subprocess.TimeoutExpired) as exc:
181
+ return False, str(exc)
182
+
183
+ output = (result.stdout or "") + (result.stderr or "")
184
+ if result.returncode != 0:
185
+ return False, output
186
+ if "Mitmproxy:" not in output:
187
+ return False, output
188
+ return True, output
189
+
190
+
191
+ def resolve_mitmdump() -> str:
192
+ """Find the mitmdump binary on PATH or known locations.
193
+
194
+ Raises FileNotFoundError if not found, RuntimeError if unusable.
195
+ """
196
+ pinned_override = os.getenv(PINNED_MITM_ENV, "").strip()
197
+ if pinned_override:
198
+ resolved = shutil.which(pinned_override)
199
+ if resolved:
200
+ ok, details = _probe_mitmdump(resolved)
201
+ if ok:
202
+ return resolved
203
+ # Stale runtime pin: drop it and continue normal discovery/install flow.
204
+ os.environ.pop(PINNED_MITM_ENV, None)
205
+ if _is_pinned_mitmdump_path(pinned_override):
206
+ os.environ.pop("MITM_BIN", None)
207
+
208
+ override = os.getenv("MITM_BIN", "").strip()
209
+ if override:
210
+ resolved = shutil.which(override)
211
+ if not resolved:
212
+ if _is_pinned_mitmdump_path(override):
213
+ os.environ.pop("MITM_BIN", None)
214
+ else:
215
+ raise FileNotFoundError(
216
+ "MITM_BIN is set but does not resolve to an executable."
217
+ )
218
+ else:
219
+ ok, details = _probe_mitmdump(resolved)
220
+ if ok:
221
+ return resolved
222
+ if _is_pinned_mitmdump_path(override) or _is_pinned_mitmdump_path(resolved):
223
+ # Recover from stale/broken internal pin by falling back to PATH
224
+ # discovery instead of hard-failing.
225
+ os.environ.pop("MITM_BIN", None)
226
+ else:
227
+ raise RuntimeError(
228
+ "MITM_BIN points to an unusable mitmdump binary. "
229
+ "Fix MITM_BIN or reinstall mitmproxy.\n"
230
+ f"{tail_text(details)}"
231
+ )
232
+
233
+ candidates: list[str] = []
234
+ candidates.extend(_iter_path_executables("mitmdump"))
235
+ candidates.extend(
236
+ [
237
+ "/opt/homebrew/bin/mitmdump",
238
+ "/usr/local/bin/mitmdump",
239
+ ]
240
+ )
241
+
242
+ seen: set[str] = set()
243
+ unique: list[str] = []
244
+ for candidate in candidates:
245
+ if candidate in seen:
246
+ continue
247
+ seen.add(candidate)
248
+ unique.append(candidate)
249
+
250
+ if not unique:
251
+ raise FileNotFoundError(
252
+ "mitmdump not found. Install mitmproxy or set MITM_BIN."
253
+ )
254
+
255
+ failures: list[str] = []
256
+ for candidate in unique:
257
+ ok, details = _probe_mitmdump(candidate)
258
+ if ok:
259
+ return candidate
260
+ failures.append(f"{candidate}: {tail_text(details)}")
261
+
262
+ joined = "\n".join(failures)
263
+ raise RuntimeError(
264
+ "Found mitmdump on PATH, but all candidates failed health checks.\n"
265
+ f"{joined}"
266
+ )
267
+
268
+
269
+ def _run_install_command(cmd: list[str]) -> tuple[bool, str]:
270
+ """Run an install command. Returns (success, combined_output)."""
271
+ try:
272
+ result = subprocess.run(cmd, check=False, capture_output=True, text=True)
273
+ except OSError as exc:
274
+ return False, str(exc)
275
+ combined = (result.stdout or "") + (result.stderr or "")
276
+ if result.returncode == 0:
277
+ return True, combined
278
+ return False, f"exit={result.returncode}\n{combined}"
279
+
280
+
281
+ def _is_user_site_hidden_error(output: str) -> bool:
282
+ """Return True when pip rejects --user inside a virtualenv."""
283
+ return (
284
+ "Can not perform a '--user' install." in output
285
+ and "User site-packages are not visible in this virtualenv." in output
286
+ )
287
+
288
+
289
+ def ensure_mitmdump(log_path: Path) -> str:
290
+ """Find or auto-install mitmdump. Returns the binary path.
291
+
292
+ Tries pipx, pip --user, and brew (macOS) in order.
293
+ Raises FileNotFoundError if all attempts fail.
294
+ """
295
+ _prepend_user_bin_dirs()
296
+ try:
297
+ return _pin_mitmdump_binary(resolve_mitmdump(), log_path=log_path)
298
+ except RuntimeError as exc:
299
+ append_log(log_path, tail_text(f"Existing mitmdump is unusable:\n{exc}"))
300
+ except FileNotFoundError:
301
+ append_log(log_path, "mitmdump not found on PATH.")
302
+
303
+ append_log(
304
+ log_path, "Installing mitmproxy (first run setup or binary repair)."
305
+ )
306
+
307
+ install_attempts: list[list[str]] = []
308
+ if shutil.which("pipx"):
309
+ install_attempts.append(["pipx", "install", "--force", "mitmproxy"])
310
+ install_attempts.append(
311
+ [sys.executable, "-m", "pip", "install", "--user", "mitmproxy"]
312
+ )
313
+ if sys.platform == "darwin" and shutil.which("brew"):
314
+ install_attempts.append(["brew", "install", "mitmproxy"])
315
+
316
+ for attempt in install_attempts:
317
+ append_log(log_path, f"Trying install command: {fmt_cmd(attempt)}")
318
+ ok, output = _run_install_command(attempt)
319
+ if not ok and "--user" in attempt and _is_user_site_hidden_error(output):
320
+ retry = [part for part in attempt if part != "--user"]
321
+ append_log(
322
+ log_path,
323
+ "pip rejected --user install in virtualenv; retrying without --user.",
324
+ )
325
+ append_log(log_path, f"Trying install command: {fmt_cmd(retry)}")
326
+ retry_ok, retry_output = _run_install_command(retry)
327
+ if retry_output.strip():
328
+ output = f"{output}\n{retry_output}"
329
+ ok = retry_ok
330
+ if ok:
331
+ _prepend_user_bin_dirs()
332
+ try:
333
+ return _pin_mitmdump_binary(resolve_mitmdump(), log_path=log_path)
334
+ except RuntimeError as exc:
335
+ append_log(
336
+ log_path,
337
+ tail_text(f"mitmdump still unhealthy after install attempt:\n{exc}"),
338
+ )
339
+ except FileNotFoundError:
340
+ if output.strip():
341
+ append_log(log_path, tail_text(output))
342
+ continue
343
+ if output.strip():
344
+ append_log(log_path, tail_text(output))
345
+
346
+ raise FileNotFoundError(
347
+ "Unable to install a usable mitmdump automatically. "
348
+ "Install mitmproxy manually and retry."
349
+ )
350
+
351
+
352
+ def allocate_port(host: str = "127.0.0.1", fallback: int = 0) -> int:
353
+ """Allocate a random available port from the OS.
354
+
355
+ Binds to port 0, reads the assigned port, closes the socket.
356
+ Falls back to *fallback* if allocation fails.
357
+ """
358
+ try:
359
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
360
+ sock.bind((host, 0))
361
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
362
+ port = sock.getsockname()[1]
363
+ return port
364
+ except OSError:
365
+ if fallback:
366
+ return fallback
367
+ raise
368
+
369
+
370
+ def resolve_proxy_host(listen_host: str) -> str:
371
+ """Map 0.0.0.0 to 127.0.0.1 for proxy URL construction."""
372
+ if listen_host == "0.0.0.0":
373
+ return "127.0.0.1"
374
+ return listen_host
375
+
376
+
377
+ def stop_process(proc: subprocess.Popen[Any]) -> None:
378
+ """Terminate a subprocess, escalating to kill after 3s timeout."""
379
+ if proc.poll() is not None:
380
+ return
381
+ proc.terminate()
382
+ try:
383
+ proc.wait(timeout=3)
384
+ except subprocess.TimeoutExpired:
385
+ proc.kill()
386
+
387
+
388
+ def build_mitmdump_cmd(
389
+ mitmdump_bin: str,
390
+ host: str,
391
+ port: int,
392
+ addon_paths: list[str],
393
+ target_path: str,
394
+ wrapper_log_file: str,
395
+ instructions_file: str = "",
396
+ instructions_text: str | None = None,
397
+ instructions_text_explicit: bool = False,
398
+ base_instructions_file: str = "",
399
+ project_instructions_file: str = "",
400
+ tool_instructions_file: str = "",
401
+ canary_rule: str = "",
402
+ passthrough: bool = False,
403
+ debug_requests: bool = False,
404
+ rewrite_test_mode: str = "",
405
+ developer_instructions_mode: str = "",
406
+ rewrite_test_tag: str = "",
407
+ codex_developer_prompt_file: str = "",
408
+ traffic_caller: str = "",
409
+ traffic_max_age_days: int = 0,
410
+ traffic_redact: bool = True,
411
+ prompt_recv_prefix_file: str = "",
412
+ prompt_context_cwd: str = "",
413
+ ) -> list[str]:
414
+ """Build the mitmdump command line with addon scripts and options.
415
+
416
+ Addons use prompt_builder to read instruction files fresh on each request.
417
+ We pass file paths (not text blobs) so the command line stays short and
418
+ file edits take effect in real time without restarting the proxy.
419
+ ``instructions_text`` is ``None`` unless the user explicitly provides an
420
+ inline override. An explicit empty string must still be forwarded so the
421
+ addons do not silently fall back to the instructions file.
422
+ """
423
+ cmd = [
424
+ mitmdump_bin,
425
+ "--quiet",
426
+ "--listen-host",
427
+ host,
428
+ "-p",
429
+ str(port),
430
+ ]
431
+
432
+ for addon_path in addon_paths:
433
+ cmd.extend(["-s", addon_path])
434
+
435
+ cmd.extend(["--set", f"target_path={target_path}"])
436
+ cmd.extend(["--set", f"wrapper_log_file={wrapper_log_file}"])
437
+
438
+ if instructions_file:
439
+ cmd.extend(["--set", f"system_instructions_file={instructions_file}"])
440
+ if instructions_text is not None:
441
+ cmd.extend(["--set", f"system_instructions_text={instructions_text}"])
442
+ if instructions_text_explicit:
443
+ cmd.extend(["--set", "system_instructions_text_explicit=true"])
444
+ if base_instructions_file:
445
+ cmd.extend(["--set", f"base_instructions_file={base_instructions_file}"])
446
+ if project_instructions_file:
447
+ cmd.extend(["--set", f"project_instructions_file={project_instructions_file}"])
448
+ if tool_instructions_file:
449
+ cmd.extend(["--set", f"tool_instructions_file={tool_instructions_file}"])
450
+ if canary_rule:
451
+ cmd.extend(["--set", f"canary_rule={canary_rule}"])
452
+ if passthrough:
453
+ cmd.extend(["--set", "passthrough=true"])
454
+ if debug_requests:
455
+ cmd.extend(["--set", "debug_requests=true"])
456
+ if rewrite_test_mode:
457
+ cmd.extend(["--set", f"rewrite_test_mode={rewrite_test_mode}"])
458
+ if developer_instructions_mode:
459
+ cmd.extend(["--set", f"developer_instructions_mode={developer_instructions_mode}"])
460
+ if rewrite_test_tag:
461
+ cmd.extend(["--set", f"rewrite_test_tag={rewrite_test_tag}"])
462
+ if codex_developer_prompt_file:
463
+ cmd.extend(["--set", f"codex_developer_prompt_file={codex_developer_prompt_file}"])
464
+ if traffic_caller:
465
+ cmd.extend(["--set", f"traffic_caller={traffic_caller}"])
466
+ if traffic_max_age_days > 0:
467
+ cmd.extend(["--set", f"traffic_max_age_days={traffic_max_age_days}"])
468
+ if not traffic_redact:
469
+ cmd.extend(["--set", "traffic_redact=false"])
470
+ if prompt_recv_prefix_file:
471
+ cmd.extend(["--set", f"prompt_recv_prefix_file={prompt_recv_prefix_file}"])
472
+ if prompt_context_cwd:
473
+ cmd.extend(["--set", f"prompt_context_cwd={prompt_context_cwd}"])
474
+
475
+ return cmd
476
+
477
+
478
+ def start_proxy(
479
+ cmd: list[str],
480
+ log_path: Path,
481
+ mitm_log_path: Path,
482
+ ) -> subprocess.Popen[Any]:
483
+ """Start mitmdump as a background process.
484
+
485
+ Returns the Popen object. Raises RuntimeError if proxy exits immediately.
486
+ """
487
+ append_log(log_path, f"Starting mitmdump: {fmt_cmd(cmd)}")
488
+ append_log(log_path, f"mitmdump runtime log: {mitm_log_path}")
489
+
490
+ log_handle = mitm_log_path.open("w", encoding="utf-8")
491
+ proc = subprocess.Popen(
492
+ cmd,
493
+ stdin=subprocess.DEVNULL,
494
+ stdout=log_handle,
495
+ stderr=log_handle,
496
+ start_new_session=True,
497
+ )
498
+ log_handle.close()
499
+
500
+ time.sleep(0.25)
501
+ if proc.poll() is not None:
502
+ append_log(log_path, "mitmdump exited early.")
503
+ tail = tail_file(mitm_log_path, lines=80)
504
+ if tail:
505
+ append_log(log_path, "--- mitmdump startup log (tail) ---")
506
+ append_log(log_path, tail)
507
+ append_log(log_path, "--- end log tail ---")
508
+ raise RuntimeError(
509
+ f"mitmdump exited with code {proc.returncode or 1}"
510
+ )
511
+
512
+ return proc
513
+
514
+
515
+ def verify_proxy_flow(
516
+ proxy_host: str,
517
+ proxy_port: int,
518
+ log_path: Path,
519
+ startup_timeout_seconds: float = 4.0,
520
+ retry_interval_seconds: float = 0.15,
521
+ ) -> bool:
522
+ """Verify request forwarding by routing a local HTTP request through the proxy.
523
+
524
+ Mitmdump startup can be slightly delayed while loading addons, so probe the
525
+ proxy for a short grace window instead of failing on the first refused
526
+ connection.
527
+ """
528
+
529
+ class _HealthHandler(http.server.BaseHTTPRequestHandler):
530
+ token = ""
531
+
532
+ def do_GET(self) -> None: # noqa: N802 (http.server naming)
533
+ payload = self.token.encode("utf-8")
534
+ self.send_response(200)
535
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
536
+ self.send_header("Content-Length", str(len(payload)))
537
+ self.end_headers()
538
+ self.wfile.write(payload)
539
+
540
+ def log_message(self, format: str, *args: object) -> None:
541
+ return
542
+
543
+ token = f"ai-cli-proxy-health-{time.time_ns()}"
544
+ _HealthHandler.token = token
545
+ health_server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _HealthHandler)
546
+ thread = threading.Thread(target=health_server.serve_forever, daemon=True)
547
+ thread.start()
548
+
549
+ target_url = f"http://127.0.0.1:{health_server.server_port}/health"
550
+ deadline = time.monotonic() + max(startup_timeout_seconds, 0.0)
551
+ attempts = 0
552
+ last_error = ""
553
+ try:
554
+ while True:
555
+ attempts += 1
556
+ conn: http.client.HTTPConnection | None = None
557
+ try:
558
+ conn = http.client.HTTPConnection(proxy_host, proxy_port, timeout=2)
559
+ conn.request("GET", target_url)
560
+ response = conn.getresponse()
561
+ body = response.read().decode("utf-8", errors="ignore")
562
+ if response.status == 200 and token in body:
563
+ append_log(
564
+ log_path,
565
+ "Proxy health check passed (request successfully forwarded through mitmdump). "
566
+ f"attempt={attempts}",
567
+ )
568
+ return True
569
+ last_error = f"status={response.status}, body_len={len(body)}"
570
+ except OSError as exc:
571
+ last_error = str(exc)
572
+ finally:
573
+ if conn is not None:
574
+ conn.close()
575
+
576
+ now = time.monotonic()
577
+ if now >= deadline:
578
+ append_log(
579
+ log_path,
580
+ "Proxy health check failed "
581
+ f"after {attempts} attempt(s): {last_error}",
582
+ )
583
+ return False
584
+
585
+ time.sleep(max(retry_interval_seconds, 0.01))
586
+ finally:
587
+ health_server.shutdown()
588
+ health_server.server_close()
589
+ thread.join(timeout=1)
590
+
591
+
592
+ def build_proxy_env(
593
+ proxy_url: str,
594
+ ca_path: Path,
595
+ log_path: Path,
596
+ extra_env: dict[str, str] | None = None,
597
+ ) -> dict[str, str]:
598
+ """Build environment dict with proxy and SSL cert variables."""
599
+ env = os.environ.copy()
600
+
601
+ # Proxy variables (both cases for compatibility)
602
+ env["HTTP_PROXY"] = proxy_url
603
+ env["HTTPS_PROXY"] = proxy_url
604
+ env["ALL_PROXY"] = proxy_url
605
+ env["http_proxy"] = proxy_url
606
+ env["https_proxy"] = proxy_url
607
+ env.setdefault("NO_PROXY", "localhost,127.0.0.1")
608
+ env.setdefault("no_proxy", "localhost,127.0.0.1")
609
+
610
+ # SSL certificate trust
611
+ ca_path_str = str(ca_path)
612
+ if ca_path.is_file():
613
+ env["SSL_CERT_FILE"] = ca_path_str
614
+ env["REQUESTS_CA_BUNDLE"] = ca_path_str
615
+ env["NODE_EXTRA_CA_CERTS"] = ca_path_str
616
+ else:
617
+ append_log(
618
+ log_path,
619
+ f"Warning: CA cert not found at {ca_path_str}. "
620
+ "TLS requests through mitmproxy may fail.",
621
+ )
622
+
623
+ if extra_env:
624
+ env.update(extra_env)
625
+
626
+ apply_pinned_mitmdump_path(env)
627
+ return env