codex-watch 0.1.1__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.
- codex_watch/__init__.py +3 -0
- codex_watch/__main__.py +3 -0
- codex_watch/addon.py +176 -0
- codex_watch/analysis.py +517 -0
- codex_watch/cli.py +235 -0
- codex_watch/core.py +226 -0
- codex_watch/lifecycle.py +33 -0
- codex_watch/monitor.py +496 -0
- codex_watch/platform_support.py +81 -0
- codex_watch/removal.py +139 -0
- codex_watch-0.1.1.dist-info/METADATA +190 -0
- codex_watch-0.1.1.dist-info/RECORD +14 -0
- codex_watch-0.1.1.dist-info/WHEEL +4 -0
- codex_watch-0.1.1.dist-info/entry_points.txt +2 -0
codex_watch/__init__.py
ADDED
codex_watch/__main__.py
ADDED
codex_watch/addon.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""mitmproxy addon: JSONL request/response/error/WebSocket events."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mitmproxy import ctx, exceptions, http
|
|
12
|
+
|
|
13
|
+
from codex_watch.core import BodyCapture, SSEEventDecoder, body_snapshot, host_allowed, redact_headers, redact_text, redact_url
|
|
14
|
+
from codex_watch.platform_support import restrict_file
|
|
15
|
+
|
|
16
|
+
DEFAULT_HOST_FILTER = "api.openai.com,chatgpt.com"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def timestamp(value: float | None = None) -> str:
|
|
20
|
+
return (datetime.fromtimestamp(value, timezone.utc) if value is not None
|
|
21
|
+
else datetime.now(timezone.utc)).isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CodexCapture:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self.responses: dict[str, BodyCapture] = {}
|
|
27
|
+
self.output: Path | None = None
|
|
28
|
+
|
|
29
|
+
def load(self, loader: Any) -> None:
|
|
30
|
+
loader.add_option("capture_output", str, "./captures/codex.jsonl", "Append JSONL events here.")
|
|
31
|
+
loader.add_option("capture_hosts", str, DEFAULT_HOST_FILTER, "Domain list; empty captures all.")
|
|
32
|
+
loader.add_option("capture_body", bool, False, "Save redacted, bounded body previews.")
|
|
33
|
+
loader.add_option("capture_max_bytes", int, 1048576, "Maximum decoded body preview bytes.")
|
|
34
|
+
|
|
35
|
+
def configure(self, updated: set[str]) -> None:
|
|
36
|
+
if ctx.options.capture_max_bytes < 0:
|
|
37
|
+
raise exceptions.OptionsError("capture_max_bytes must be non-negative")
|
|
38
|
+
if "capture_output" in updated or self.output is None:
|
|
39
|
+
output = Path(ctx.options.capture_output).expanduser()
|
|
40
|
+
try:
|
|
41
|
+
output.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
42
|
+
# Restrict capture files even if they already exist.
|
|
43
|
+
fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
44
|
+
try:
|
|
45
|
+
restrict_file(fd)
|
|
46
|
+
finally:
|
|
47
|
+
os.close(fd)
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
raise exceptions.OptionsError(f"Cannot open capture_output: {exc}") from exc
|
|
50
|
+
self.output = output
|
|
51
|
+
|
|
52
|
+
def _capture(self, message: http.Message) -> BodyCapture:
|
|
53
|
+
return BodyCapture(
|
|
54
|
+
include_body=ctx.options.capture_body, max_bytes=ctx.options.capture_max_bytes,
|
|
55
|
+
content_type=message.headers.get("content-type", ""),
|
|
56
|
+
content_encoding=message.headers.get("content-encoding", ""),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _base(self, flow: http.HTTPFlow, event: str) -> dict[str, Any]:
|
|
60
|
+
return {
|
|
61
|
+
"schema_version": 2, "event": event, "flow_id": flow.id,
|
|
62
|
+
"captured_at": timestamp(), "method": flow.request.method,
|
|
63
|
+
"url": redact_url(flow.request.pretty_url),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def request(self, flow: http.HTTPFlow) -> None:
|
|
67
|
+
allowed = host_allowed(flow.request.host, ctx.options.capture_hosts)
|
|
68
|
+
flow.metadata["codex_capture_allowed"] = allowed
|
|
69
|
+
if not allowed:
|
|
70
|
+
return
|
|
71
|
+
capture = self._capture(flow.request)
|
|
72
|
+
capture.feed(flow.request.raw_content or b"")
|
|
73
|
+
record = self._base(flow, "request")
|
|
74
|
+
record["request"] = {
|
|
75
|
+
"timestamp": timestamp(flow.request.timestamp_start),
|
|
76
|
+
"http_version": flow.request.http_version,
|
|
77
|
+
"headers": redact_headers(flow.request.headers.items(multi=True)),
|
|
78
|
+
"body": capture.snapshot(),
|
|
79
|
+
}
|
|
80
|
+
self._write(record)
|
|
81
|
+
|
|
82
|
+
def responseheaders(self, flow: http.HTTPFlow) -> None:
|
|
83
|
+
assert flow.response is not None
|
|
84
|
+
if flow.response.status_code == 101:
|
|
85
|
+
return # WebSocket has its own message hooks.
|
|
86
|
+
if flow.metadata.get("codex_capture_allowed"):
|
|
87
|
+
capture = self._capture(flow.response)
|
|
88
|
+
self.responses[flow.id] = capture
|
|
89
|
+
if ctx.options.capture_body and capture.content_type == "text/event-stream":
|
|
90
|
+
def emit(payload):
|
|
91
|
+
index = flow.metadata.get("codex_capture_sse_index", 0)
|
|
92
|
+
flow.metadata["codex_capture_sse_index"] = index + 1
|
|
93
|
+
record = self._base(flow, "sse_event")
|
|
94
|
+
record.update(index=index, body={"captured": True, "json": payload},
|
|
95
|
+
response_headers=redact_headers(flow.response.headers.items(multi=True)))
|
|
96
|
+
self._write(record)
|
|
97
|
+
|
|
98
|
+
decoder = SSEEventDecoder(emit, ctx.options.capture_max_bytes)
|
|
99
|
+
|
|
100
|
+
def stream(chunk):
|
|
101
|
+
start = len(capture.sample)
|
|
102
|
+
result = capture.feed(chunk)
|
|
103
|
+
if not capture.problem:
|
|
104
|
+
decoder.feed(bytes(capture.sample[start:capture.limit]))
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
flow.response.stream = stream
|
|
108
|
+
else:
|
|
109
|
+
flow.response.stream = capture.feed
|
|
110
|
+
else:
|
|
111
|
+
flow.response.stream = True
|
|
112
|
+
|
|
113
|
+
def response(self, flow: http.HTTPFlow) -> None:
|
|
114
|
+
if flow.metadata.get("codex_capture_allowed"):
|
|
115
|
+
self._write(self._response_record(flow, "response"))
|
|
116
|
+
|
|
117
|
+
def _response_record(self, flow: http.HTTPFlow, event: str) -> dict[str, Any]:
|
|
118
|
+
record = self._base(flow, event)
|
|
119
|
+
capture = self.responses.pop(flow.id, None)
|
|
120
|
+
response = flow.response
|
|
121
|
+
if response is not None:
|
|
122
|
+
if capture is None:
|
|
123
|
+
capture = self._capture(response)
|
|
124
|
+
capture.feed(response.raw_content or b"")
|
|
125
|
+
record["response"] = {
|
|
126
|
+
"status_code": response.status_code, "http_version": response.http_version,
|
|
127
|
+
"headers": redact_headers(response.headers.items(multi=True)),
|
|
128
|
+
"body": capture.snapshot(),
|
|
129
|
+
}
|
|
130
|
+
end = response.timestamp_end
|
|
131
|
+
if end is not None and flow.request.timestamp_start is not None:
|
|
132
|
+
record["duration_ms"] = round((end - flow.request.timestamp_start) * 1000, 3)
|
|
133
|
+
else:
|
|
134
|
+
record["response"] = None
|
|
135
|
+
return record
|
|
136
|
+
|
|
137
|
+
def error(self, flow: http.HTTPFlow) -> None:
|
|
138
|
+
if flow.metadata.get("codex_capture_allowed"):
|
|
139
|
+
record = self._response_record(flow, "error")
|
|
140
|
+
record["error"] = redact_text(str(flow.error))
|
|
141
|
+
self._write(record)
|
|
142
|
+
else:
|
|
143
|
+
self.responses.pop(flow.id, None)
|
|
144
|
+
|
|
145
|
+
def websocket_message(self, flow: http.HTTPFlow) -> None:
|
|
146
|
+
if not flow.metadata.get("codex_capture_allowed") or flow.websocket is None:
|
|
147
|
+
return
|
|
148
|
+
message = flow.websocket.messages[-1]
|
|
149
|
+
index = flow.metadata.get("codex_capture_ws_index", 0)
|
|
150
|
+
flow.metadata["codex_capture_ws_index"] = index + 1
|
|
151
|
+
record = self._base(flow, "websocket_message")
|
|
152
|
+
record.update(
|
|
153
|
+
index=index, direction="client_to_server" if message.from_client else "server_to_client",
|
|
154
|
+
timestamp=timestamp(message.timestamp), is_text=message.is_text,
|
|
155
|
+
body=body_snapshot(message.content, include_body=ctx.options.capture_body and message.is_text,
|
|
156
|
+
max_bytes=ctx.options.capture_max_bytes),
|
|
157
|
+
)
|
|
158
|
+
self._write(record)
|
|
159
|
+
|
|
160
|
+
def websocket_end(self, flow: http.HTTPFlow) -> None:
|
|
161
|
+
if flow.metadata.get("codex_capture_allowed") and flow.websocket is not None:
|
|
162
|
+
record = self._base(flow, "websocket_end")
|
|
163
|
+
record.update(close_code=flow.websocket.close_code,
|
|
164
|
+
close_reason=redact_text(flow.websocket.close_reason or ""))
|
|
165
|
+
self._write(record)
|
|
166
|
+
|
|
167
|
+
def _write(self, record: dict[str, Any]) -> None:
|
|
168
|
+
assert self.output is not None
|
|
169
|
+
try:
|
|
170
|
+
with self.output.open("a", encoding="utf-8") as handle:
|
|
171
|
+
handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
logging.error("Capture write failed (%s): %s", self.output, exc)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
addons = [CodexCapture()]
|