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/__init__.py +3 -0
- ai_cli/__main__.py +6 -0
- ai_cli/bin/ai-mux-linux-x86_64 +0 -0
- ai_cli/bin/remote-tty-wrapper +153 -0
- ai_cli/ca.py +175 -0
- ai_cli/completion_gen.py +680 -0
- ai_cli/config.py +185 -0
- ai_cli/credentials.py +341 -0
- ai_cli/detached_cleanup.py +135 -0
- ai_cli/housekeeping.py +50 -0
- ai_cli/instructions.py +308 -0
- ai_cli/log.py +53 -0
- ai_cli/main.py +1516 -0
- ai_cli/main_helpers.py +553 -0
- ai_cli/prompt_editor_launcher.py +324 -0
- ai_cli/proxy.py +627 -0
- ai_cli/remote.py +669 -0
- ai_cli/remote_package.py +1111 -0
- ai_cli/session.py +1344 -0
- ai_cli/session_store.py +236 -0
- ai_cli/traffic.py +1510 -0
- ai_cli/traffic_db.py +118 -0
- ai_cli/tui.py +525 -0
- ai_cli/update.py +200 -0
- ai_cli_toolkit-0.2.0.dist-info/METADATA +17 -0
- ai_cli_toolkit-0.2.0.dist-info/RECORD +30 -0
- ai_cli_toolkit-0.2.0.dist-info/WHEEL +5 -0
- ai_cli_toolkit-0.2.0.dist-info/entry_points.txt +2 -0
- ai_cli_toolkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- ai_cli_toolkit-0.2.0.dist-info/top_level.txt +1 -0
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
|