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.
@@ -0,0 +1,3 @@
1
+ """Local Codex traffic capture and searchable chat monitoring."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,3 @@
1
+ from codex_watch.cli import main
2
+
3
+ raise SystemExit(main())
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()]