canarywire 0.1.0a1__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.
Files changed (57) hide show
  1. canarywire/__init__.py +3 -0
  2. canarywire/__main__.py +5 -0
  3. canarywire/canaries.py +15 -0
  4. canarywire/capture/__init__.py +0 -0
  5. canarywire/capture/app.py +344 -0
  6. canarywire/capture/daemon.py +197 -0
  7. canarywire/capture/recorder.py +24 -0
  8. canarywire/capture/relay.py +99 -0
  9. canarywire/cli.py +188 -0
  10. canarywire/config.py +592 -0
  11. canarywire/loopback.py +19 -0
  12. canarywire/py.typed +0 -0
  13. canarywire/report/__init__.py +222 -0
  14. canarywire/report/context.py +46 -0
  15. canarywire/report/findings.py +146 -0
  16. canarywire/report/mustache.py +259 -0
  17. canarywire/report/outputs.py +122 -0
  18. canarywire/report/samples.py +216 -0
  19. canarywire/report/templates/junit.xml.mustache +31 -0
  20. canarywire/report/templates/markdown.md.mustache +72 -0
  21. canarywire/report/view.py +293 -0
  22. canarywire/rpc.py +191 -0
  23. canarywire/runner/__init__.py +0 -0
  24. canarywire/runner/attacks.py +385 -0
  25. canarywire/runner/catalog.py +385 -0
  26. canarywire/runner/consistency.py +75 -0
  27. canarywire/runner/fault.py +274 -0
  28. canarywire/runner/fragmentation.py +296 -0
  29. canarywire/runner/hooks.py +73 -0
  30. canarywire/runner/jsonpath.py +97 -0
  31. canarywire/runner/messages.py +83 -0
  32. canarywire/runner/neutral.py +155 -0
  33. canarywire/runner/prepare.py +284 -0
  34. canarywire/runner/protocols/__init__.py +40 -0
  35. canarywire/runner/protocols/anthropic.py +218 -0
  36. canarywire/runner/protocols/openai.py +186 -0
  37. canarywire/runner/restore.py +134 -0
  38. canarywire/runner/runner.py +236 -0
  39. canarywire/runner/scan.py +181 -0
  40. canarywire/runner/session.py +151 -0
  41. canarywire/runner/splits.py +96 -0
  42. canarywire/templates/__init__.py +0 -0
  43. canarywire/templates/default.yaml +13 -0
  44. canarywire/templates/multi-turn.yaml +16 -0
  45. canarywire/templates/tool-calls.yaml +34 -0
  46. canarywire/values/__init__.py +0 -0
  47. canarywire/values/builtins.py +202 -0
  48. canarywire/values/checksums.py +91 -0
  49. canarywire/values/errors.py +5 -0
  50. canarywire/values/formats.py +34 -0
  51. canarywire/values/regex.py +300 -0
  52. canarywire/values/types.py +269 -0
  53. canarywire-0.1.0a1.dist-info/METADATA +134 -0
  54. canarywire-0.1.0a1.dist-info/RECORD +57 -0
  55. canarywire-0.1.0a1.dist-info/WHEEL +4 -0
  56. canarywire-0.1.0a1.dist-info/entry_points.txt +2 -0
  57. canarywire-0.1.0a1.dist-info/licenses/LICENSE +202 -0
canarywire/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """canarywire: a regression test for your PII gateway's trust boundary."""
2
+
3
+ __version__ = "0.1.0a1"
canarywire/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from canarywire.cli import main
4
+
5
+ sys.exit(main())
canarywire/canaries.py ADDED
@@ -0,0 +1,15 @@
1
+ """Canaries: synthetic values in real formats, assigned per template instance for a run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Canary:
10
+ """One tested value: a template's canary instance (`name`) with its type and template."""
11
+
12
+ name: str
13
+ value: str
14
+ type: str = ""
15
+ template: str = ""
File without changes
@@ -0,0 +1,344 @@
1
+ """The capture server: records every upstream request and relays it to the connected runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import itertools
7
+ import json
8
+ import os
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import anyio
14
+ from starlette.applications import Starlette
15
+ from starlette.background import BackgroundTask
16
+ from starlette.responses import PlainTextResponse, Response, StreamingResponse
17
+ from starlette.routing import Route, WebSocketRoute
18
+ from starlette.websockets import WebSocketDisconnect
19
+
20
+ from canarywire.capture.relay import (
21
+ NoRunnerError,
22
+ Relay,
23
+ RunnerLostError,
24
+ Session,
25
+ forward,
26
+ notify_done,
27
+ )
28
+ from canarywire.rpc import ChannelClosedError, Peer, RpcError
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import AsyncGenerator
32
+
33
+ from starlette.requests import Request
34
+ from starlette.types import Receive, Scope, Send
35
+ from starlette.websockets import WebSocket
36
+
37
+ from canarywire.capture.recorder import Recorder
38
+
39
+ METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
40
+ SESSION_CONFLICT = -32000
41
+ POLICY_VIOLATION = 1008
42
+ MIN_STATUS = 100
43
+ MAX_STATUS = 599
44
+ SUCCESS_MIN = 200
45
+ NO_CONTENT = 204
46
+ NOT_MODIFIED = 304
47
+ HEADER_PAIR = 2
48
+ PID_HEADER = "x-canarywire-pid"
49
+ DROPPED_HEADERS = frozenset({"content-length", "transfer-encoding", "connection"})
50
+ # Credentials the gateway sends its upstream: relayed to the runner, never written to disk.
51
+ SECRET_HEADERS = frozenset(
52
+ {"authorization", "proxy-authorization", "x-api-key", "api-key", "x-goog-api-key", "cookie"}
53
+ )
54
+ REDACTED = "[redacted]"
55
+
56
+
57
+ class StarletteChannel:
58
+ """RPC channel over the capture's side of the runner WebSocket."""
59
+
60
+ def __init__(self, websocket: WebSocket) -> None:
61
+ """Wrap an accepted WebSocket."""
62
+ self._websocket = websocket
63
+
64
+ async def send(self, text: str) -> None:
65
+ """Send one text frame."""
66
+ try:
67
+ await self._websocket.send_text(text)
68
+ except (WebSocketDisconnect, RuntimeError) as exc:
69
+ raise ChannelClosedError from exc
70
+
71
+ async def receive(self) -> str:
72
+ """Receive one text frame."""
73
+ try:
74
+ return await self._websocket.receive_text()
75
+ except (WebSocketDisconnect, RuntimeError, KeyError) as exc:
76
+ raise ChannelClosedError from exc
77
+
78
+ async def close(self) -> None:
79
+ """Close the socket with a policy-violation code."""
80
+ with contextlib.suppress(RuntimeError):
81
+ await self._websocket.close(code=POLICY_VIOLATION)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class ResponsePlan:
86
+ """A validated `upstream.request` result: a plain body or a list of stream chunks."""
87
+
88
+ status: int
89
+ headers: list[tuple[str, str]]
90
+ body: str | None
91
+ chunks: list[tuple[str, int]] | None
92
+
93
+ @classmethod
94
+ def parse(cls, result: object) -> ResponsePlan:
95
+ """Validate a runner answer; raise ValueError if malformed."""
96
+ try:
97
+ return cls._parse(result)
98
+ except (AttributeError, TypeError) as exc:
99
+ raise ValueError(f"malformed response spec: {exc!r}") from exc
100
+
101
+ @classmethod
102
+ def _parse(cls, result: object) -> ResponsePlan:
103
+ if not isinstance(result, dict):
104
+ raise ValueError("response spec must be an object")
105
+ status = result.get("status")
106
+ if isinstance(status, bool) or not isinstance(status, int):
107
+ raise ValueError("status must be an integer")
108
+ if not MIN_STATUS <= status <= MAX_STATUS:
109
+ raise ValueError("status out of range")
110
+ headers = _parse_headers(result.get("headers", []))
111
+ body = result.get("body")
112
+ stream = result.get("stream")
113
+ if (body is None) == (stream is None):
114
+ raise ValueError("exactly one of body and stream is required")
115
+ if body is not None:
116
+ if not isinstance(body, str):
117
+ raise ValueError("body must be a string")
118
+ if body and not _may_have_body(status):
119
+ raise ValueError(f"status {status} cannot carry a body")
120
+ return cls(status, headers, body, None)
121
+ if not isinstance(stream, list):
122
+ raise ValueError("stream must be a list")
123
+ chunks: list[tuple[str, int]] = []
124
+ for chunk in stream:
125
+ data = chunk.get("data")
126
+ delay = chunk.get("delay_ms", 0)
127
+ if not isinstance(data, str) or isinstance(delay, bool) or not isinstance(delay, int):
128
+ raise ValueError("stream chunk needs data (string) and delay_ms (integer)")
129
+ chunks.append((data, max(delay, 0)))
130
+ if chunks and not _may_have_body(status):
131
+ raise ValueError(f"status {status} cannot carry a body")
132
+ return cls(status, headers, None, chunks)
133
+
134
+
135
+ def _may_have_body(status: int) -> bool:
136
+ """1xx, 204 and 304 responses must not carry a body (RFC 9110)."""
137
+ return status >= SUCCESS_MIN and status not in (NO_CONTENT, NOT_MODIFIED)
138
+
139
+
140
+ def _parse_headers(raw: object) -> list[tuple[str, str]]:
141
+ """Headers must be a list of [name, value] string pairs; anything else is a runner error."""
142
+ if not isinstance(raw, list):
143
+ raise ValueError("headers must be a list of [name, value] pairs")
144
+ headers: list[tuple[str, str]] = []
145
+ for pair in raw:
146
+ if not (
147
+ isinstance(pair, list)
148
+ and len(pair) == HEADER_PAIR
149
+ and all(isinstance(part, str) for part in pair)
150
+ ):
151
+ raise ValueError("each header must be a [name, value] pair of strings")
152
+ headers.append((pair[0], pair[1]))
153
+ return headers
154
+
155
+
156
+ def create_app(recorder: Recorder) -> Starlette:
157
+ """Build the capture ASGI app; `recorder` receives one entry per upstream request."""
158
+ relay = Relay()
159
+ ids = itertools.count(1)
160
+
161
+ async def health(_: Request) -> Response:
162
+ # The pid lets `serve --detach` tell its own child from another capture on the same port.
163
+ return PlainTextResponse("ok", headers={PID_HEADER: str(os.getpid())})
164
+
165
+ async def reserved(_: Request) -> Response:
166
+ return PlainTextResponse("not found", status_code=404)
167
+
168
+ async def runner_socket(websocket: WebSocket) -> None:
169
+ await websocket.accept()
170
+ started: list[Session] = []
171
+
172
+ async def start(params: object) -> object:
173
+ if started:
174
+ raise RpcError(SESSION_CONFLICT, "session already started")
175
+ if relay.session is not None:
176
+ raise RpcError(SESSION_CONFLICT, "another runner session is active", close=True)
177
+ session = Session.from_params(peer, params)
178
+ started.append(session)
179
+ relay.start(session)
180
+ return {}
181
+
182
+ peer = Peer(StarletteChannel(websocket), {"session.start": start})
183
+ try:
184
+ await peer.run()
185
+ finally:
186
+ for session in started:
187
+ relay.end(session)
188
+
189
+ async def upstream(request: Request) -> Response:
190
+ body = (await request.body()).decode("utf-8", errors="replace")
191
+ params: dict[str, Any] = {
192
+ "id": f"u{next(ids)}",
193
+ "method": request.method,
194
+ "path": request.url.path,
195
+ "query": request.url.query,
196
+ "headers": [
197
+ [name.decode("latin-1").lower(), value.decode("latin-1")]
198
+ for name, value in request.headers.raw
199
+ ],
200
+ "body": body,
201
+ "json": _parse_json(body),
202
+ }
203
+ session = relay.session
204
+ entry: dict[str, Any] = {
205
+ "ts": _now(),
206
+ "run_id": session.run_id if session else None,
207
+ "seed": session.seed if session else None,
208
+ "id": params["id"],
209
+ "request": _redacted(params),
210
+ }
211
+ try:
212
+ plan = ResponsePlan.parse(await forward(session, params))
213
+ except NoRunnerError:
214
+ return _refused(recorder, entry, 503, "no_runner", "no canarywire runner connected")
215
+ except RunnerLostError:
216
+ return _refused(recorder, entry, 503, "runner_lost", "canarywire runner disconnected")
217
+ except TimeoutError:
218
+ return _refused(recorder, entry, 504, "timeout", "canarywire runner did not answer")
219
+ except (RpcError, ValueError):
220
+ return _refused(recorder, entry, 502, "runner_error", "canarywire runner failed")
221
+ if plan.body is not None:
222
+ return _body_response(plan, plan.body, entry, recorder, session)
223
+ return _stream_response(plan, plan.chunks or [], entry, recorder, session)
224
+
225
+ return Starlette(
226
+ routes=[
227
+ Route("/_canarywire/health", health),
228
+ WebSocketRoute("/_canarywire/ws", runner_socket),
229
+ Route("/_canarywire/{rest:path}", reserved, methods=METHODS),
230
+ Route("/{path:path}", upstream, methods=METHODS),
231
+ ]
232
+ )
233
+
234
+
235
+ def _now() -> str:
236
+ return datetime.now(timezone.utc).isoformat()
237
+
238
+
239
+ def _parse_json(body: str) -> Any:
240
+ if not body:
241
+ return None
242
+ try:
243
+ return json.loads(body)
244
+ except (ValueError, RecursionError):
245
+ return None
246
+
247
+
248
+ def _redacted(params: dict[str, Any]) -> dict[str, Any]:
249
+ """The request as recorded: `params` with secret header values replaced."""
250
+ headers = [
251
+ [name, REDACTED if name in SECRET_HEADERS else value] for name, value in params["headers"]
252
+ ]
253
+ return {**params, "headers": headers}
254
+
255
+
256
+ def _refused(
257
+ recorder: Recorder, entry: dict[str, Any], status: int, outcome: str, message: str
258
+ ) -> Response:
259
+ entry["response"] = {"status": status, "body": message}
260
+ entry["outcome"] = outcome
261
+ recorder.write(entry)
262
+ return PlainTextResponse(message, status_code=status)
263
+
264
+
265
+ def _with_headers(response: Response, headers: list[tuple[str, str]]) -> Response:
266
+ for name, value in headers:
267
+ if name.lower() not in DROPPED_HEADERS:
268
+ response.headers.append(name, value)
269
+ return response
270
+
271
+
272
+ def _body_response(
273
+ plan: ResponsePlan,
274
+ body: str,
275
+ entry: dict[str, Any],
276
+ recorder: Recorder,
277
+ session: Session | None,
278
+ ) -> Response:
279
+ async def finish() -> None:
280
+ entry["response"] = {"status": plan.status, "headers": plan.headers, "body": body}
281
+ entry["outcome"] = "completed"
282
+ recorder.write(entry)
283
+ await notify_done(
284
+ session, {"id": entry["id"], "outcome": "completed", "delivered_chunks": 1}
285
+ )
286
+
287
+ response = Response(body.encode(), status_code=plan.status, background=BackgroundTask(finish))
288
+ return _with_headers(response, plan.headers)
289
+
290
+
291
+ class ClosingStreamingResponse(StreamingResponse):
292
+ """A StreamingResponse that always closes its generator when the response task ends.
293
+
294
+ On client disconnect Starlette cancels the send loop but never closes the body iterator, so
295
+ the generator's `finally` (which records the outcome and sends `upstream.done`) would
296
+ otherwise wait for garbage collection.
297
+ """
298
+
299
+ def __init__(self, content: AsyncGenerator[bytes, None], status_code: int) -> None:
300
+ """Stream `content` with `status_code`."""
301
+ super().__init__(content, status_code=status_code)
302
+ self._generator = content
303
+
304
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
305
+ """Send the response, then close the generator however sending ended."""
306
+ try:
307
+ await super().__call__(scope, receive, send)
308
+ finally:
309
+ with anyio.CancelScope(shield=True):
310
+ await self._generator.aclose()
311
+
312
+
313
+ def _stream_response(
314
+ plan: ResponsePlan,
315
+ chunks: list[tuple[str, int]],
316
+ entry: dict[str, Any],
317
+ recorder: Recorder,
318
+ session: Session | None,
319
+ ) -> Response:
320
+ async def content() -> AsyncGenerator[bytes, None]:
321
+ delivered: list[str] = []
322
+ outcome = "aborted"
323
+ try:
324
+ for data, delay_ms in chunks:
325
+ if delay_ms:
326
+ await anyio.sleep(delay_ms / 1000)
327
+ yield data.encode()
328
+ delivered.append(data)
329
+ outcome = "completed"
330
+ finally:
331
+ with anyio.CancelScope(shield=True):
332
+ entry["response"] = {
333
+ "status": plan.status,
334
+ "headers": plan.headers,
335
+ "chunks": delivered,
336
+ }
337
+ entry["outcome"] = outcome
338
+ recorder.write(entry)
339
+ await notify_done(
340
+ session,
341
+ {"id": entry["id"], "outcome": outcome, "delivered_chunks": len(delivered)},
342
+ )
343
+
344
+ return _with_headers(ClosingStreamingResponse(content(), status_code=plan.status), plan.headers)
@@ -0,0 +1,197 @@
1
+ """Capture process lifecycle: foreground serve, --detach, stop, and the pid file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import ipaddress
7
+ import os
8
+ import re
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import anyio
15
+ import httpx
16
+ import uvicorn
17
+
18
+ from canarywire.capture.app import PID_HEADER, create_app
19
+ from canarywire.capture.recorder import Recorder
20
+ from canarywire.config import parse_listen
21
+
22
+ STATE_DIR = Path(".canarywire")
23
+ PID_FILE = "capture.pid"
24
+ LOG_FILE = "capture.log"
25
+ RECORD_FILE = "capture.jsonl"
26
+ PS = "/bin/ps"
27
+ POLL = 0.05
28
+ KILL_WAIT = 5.0
29
+ LOG_TAIL = 20
30
+ # `canarywire serve` as whole words: `python -m canarywire serve …` or `…/bin/canarywire serve …`,
31
+ # never `notcanarywire serve` or `canarywire serverless`.
32
+ CAPTURE_COMMAND = re.compile(r"(?:^|[\s/])canarywire serve(?:\s|$)")
33
+
34
+
35
+ def read_pid(path: Path) -> int | None:
36
+ """The pid in a pid file, or None if it is missing or unparsable."""
37
+ try:
38
+ return int(path.read_text().strip())
39
+ except (OSError, ValueError):
40
+ return None
41
+
42
+
43
+ def is_capture(pid: int) -> bool:
44
+ """True if `pid` is a running `canarywire serve` (not a recycled pid, not a zombie)."""
45
+ try:
46
+ os.kill(pid, 0)
47
+ except ProcessLookupError:
48
+ return False
49
+ except PermissionError:
50
+ pass # exists but belongs to someone else; ps decides
51
+ # -ww: unlimited width. procps otherwise cuts the command at $COLUMNS, and a long venv path
52
+ # would push `canarywire serve` out of view.
53
+ result = subprocess.run( # noqa: S603 - fixed argv, pid is an int
54
+ [PS, "-ww", "-o", "command=", "-p", str(pid)], capture_output=True, text=True, check=False
55
+ )
56
+ return CAPTURE_COMMAND.search(result.stdout) is not None
57
+
58
+
59
+ def live_pid(state_dir: Path) -> int | None:
60
+ """The pid of the live capture named by the pid file, if any."""
61
+ pid = read_pid(state_dir / PID_FILE)
62
+ return pid if pid is not None and is_capture(pid) else None
63
+
64
+
65
+ def serve_foreground(host: str, port: int, state_dir: Path) -> int:
66
+ """Run the capture in this process until SIGTERM or SIGINT."""
67
+ if (pid := live_pid(state_dir)) is not None:
68
+ print(f"canarywire: capture already running (pid {pid})", file=sys.stderr)
69
+ return 1
70
+ state_dir.mkdir(parents=True, exist_ok=True)
71
+ pid_file = state_dir / PID_FILE
72
+ pid_file.write_text(f"{os.getpid()}\n")
73
+ # uvicorn installs its own SIGTERM handler, shuts down gracefully, then restores the
74
+ # previous handler and re-raises the signal. Without a no-op handler in place first, that
75
+ # re-raise kills the process before this function's `finally` (and the pid file cleanup) runs.
76
+ previous_sigterm = signal.signal(signal.SIGTERM, lambda *_: None)
77
+ try:
78
+ uvicorn.run(
79
+ create_app(Recorder(state_dir / RECORD_FILE)),
80
+ host=host,
81
+ port=port,
82
+ log_level="warning",
83
+ ws="websockets-sansio",
84
+ lifespan="off",
85
+ )
86
+ finally:
87
+ signal.signal(signal.SIGTERM, previous_sigterm)
88
+ if read_pid(pid_file) == os.getpid():
89
+ pid_file.unlink(missing_ok=True)
90
+ return 0
91
+
92
+
93
+ async def serve_detached(listen: str, state_dir: Path, ready_timeout: float) -> int:
94
+ """Start `canarywire serve` in a new session; return once it answers its health check."""
95
+ if (pid := live_pid(state_dir)) is not None:
96
+ print(f"canarywire: capture already running (pid {pid})", file=sys.stderr)
97
+ return 1
98
+ host, port = parse_listen(listen)
99
+ state_dir.mkdir(parents=True, exist_ok=True)
100
+ log_path = state_dir / LOG_FILE
101
+ with log_path.open("ab") as log:
102
+ process = subprocess.Popen( # noqa: S603 - argv built from our own interpreter
103
+ [sys.executable, "-m", "canarywire", "serve", "--listen", listen],
104
+ stdin=subprocess.DEVNULL,
105
+ stdout=log,
106
+ stderr=subprocess.STDOUT,
107
+ start_new_session=True,
108
+ )
109
+ url = f"http://{_probe_host(host)}:{port}/_canarywire/health"
110
+ if await _wait_healthy(url, process, ready_timeout):
111
+ print(f"canarywire: capture listening on {listen} (pid {process.pid})")
112
+ return 0
113
+ process.kill()
114
+ process.wait()
115
+ print("canarywire: capture failed to start; last log lines:", file=sys.stderr)
116
+ print(_tail(log_path), file=sys.stderr)
117
+ return 1
118
+
119
+
120
+ async def stop(state_dir: Path, grace: float) -> int:
121
+ """Stop the capture named by the pid file; succeed when none is running."""
122
+ pid_file = state_dir / PID_FILE
123
+ pid = read_pid(pid_file)
124
+ if pid is None or not is_capture(pid):
125
+ pid_file.unlink(missing_ok=True)
126
+ print("canarywire: no capture running")
127
+ return 0
128
+ if not _signal(pid, signal.SIGTERM):
129
+ return 1
130
+ gone = await _wait_gone(pid, grace)
131
+ if not gone:
132
+ if not _signal(pid, signal.SIGKILL):
133
+ return 1
134
+ gone = await _wait_gone(pid, KILL_WAIT)
135
+ if not gone:
136
+ print(f"canarywire: capture did not stop (pid {pid})", file=sys.stderr)
137
+ return 1
138
+ pid_file.unlink(missing_ok=True)
139
+ print(f"canarywire: capture stopped (pid {pid})")
140
+ return 0
141
+
142
+
143
+ def _probe_host(host: str) -> str:
144
+ with contextlib.suppress(ValueError):
145
+ address = ipaddress.ip_address(host)
146
+ if address.is_unspecified:
147
+ return "127.0.0.1"
148
+ if address.version == 6: # noqa: PLR2004 - IP version number
149
+ return f"[{host}]"
150
+ return host
151
+
152
+
153
+ async def _wait_healthy(url: str, process: subprocess.Popen[bytes], timeout: float) -> bool:
154
+ """True once *our child* answers the health check.
155
+
156
+ Another capture already bound to the port would answer too; its pid header tells it apart.
157
+ The child writes the pid file before it serves, so a healthy child also owns the pid file.
158
+ """
159
+ # Our own child on the address it bound: never through a proxy from the environment.
160
+ async with httpx.AsyncClient(timeout=1.0, trust_env=False) as client:
161
+ with anyio.move_on_after(timeout):
162
+ while process.poll() is None:
163
+ with contextlib.suppress(httpx.TransportError):
164
+ response = await client.get(url)
165
+ if response.status_code == httpx.codes.OK and response.headers.get(
166
+ PID_HEADER
167
+ ) == str(process.pid):
168
+ return True
169
+ await anyio.sleep(POLL)
170
+ return False
171
+
172
+
173
+ async def _wait_gone(pid: int, timeout: float) -> bool:
174
+ with anyio.move_on_after(timeout):
175
+ while is_capture(pid):
176
+ await anyio.sleep(POLL)
177
+ return True
178
+ return False
179
+
180
+
181
+ def _signal(pid: int, sig: signal.Signals) -> bool:
182
+ """Send `sig` to `pid`; False (with a stderr message) only if permission was denied."""
183
+ try:
184
+ os.kill(pid, sig)
185
+ except ProcessLookupError:
186
+ return True
187
+ except PermissionError:
188
+ print(f"canarywire: cannot signal pid {pid}: permission denied", file=sys.stderr)
189
+ return False
190
+ return True
191
+
192
+
193
+ def _tail(path: Path) -> str:
194
+ try:
195
+ return "\n".join(path.read_text(errors="replace").splitlines()[-LOG_TAIL:])
196
+ except OSError:
197
+ return "<no log>"
@@ -0,0 +1,24 @@
1
+ """Append-only record of every upstream exchange."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+
12
+
13
+ class Recorder:
14
+ """Appends one JSON line per upstream exchange; every line is flushed immediately."""
15
+
16
+ def __init__(self, path: Path) -> None:
17
+ """Record to `path`, creating its directory on first write."""
18
+ self.path = path
19
+
20
+ def write(self, entry: Mapping[str, Any]) -> None:
21
+ """Append one entry."""
22
+ self.path.parent.mkdir(parents=True, exist_ok=True)
23
+ with self.path.open("a", encoding="utf-8") as file:
24
+ file.write(json.dumps(entry) + "\n")
@@ -0,0 +1,99 @@
1
+ """The single runner session and forwarding of upstream requests to it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import math
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from canarywire.rpc import INVALID_PARAMS, ChannelClosedError, RpcError
11
+
12
+ if TYPE_CHECKING:
13
+ from canarywire.rpc import Peer
14
+
15
+
16
+ class NoRunnerError(Exception):
17
+ """No runner session is active."""
18
+
19
+
20
+ class RunnerLostError(Exception):
21
+ """The runner's socket closed before it answered."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Session:
26
+ """The connected runner: its RPC peer and what it sent in `session.start`."""
27
+
28
+ peer: Peer
29
+ run_id: str
30
+ seed: int
31
+ upstream_response_timeout: float
32
+
33
+ @classmethod
34
+ def from_params(cls, peer: Peer, params: object) -> Session:
35
+ """Validate `session.start` params; raise RpcError(INVALID_PARAMS) if malformed."""
36
+ if not isinstance(params, dict):
37
+ raise RpcError(INVALID_PARAMS, "params must be an object")
38
+ run_id = params.get("run_id")
39
+ seed = params.get("seed")
40
+ timeout = params.get("upstream_response_timeout")
41
+ if not isinstance(run_id, str) or not run_id:
42
+ raise RpcError(INVALID_PARAMS, "run_id: expected a non-empty string")
43
+ if isinstance(seed, bool) or not isinstance(seed, int):
44
+ raise RpcError(INVALID_PARAMS, "seed: expected an integer")
45
+ if (
46
+ isinstance(timeout, bool)
47
+ or not isinstance(timeout, int | float)
48
+ or not math.isfinite(timeout) # json.loads accepts Infinity and NaN
49
+ or timeout <= 0
50
+ ):
51
+ raise RpcError(
52
+ INVALID_PARAMS, "upstream_response_timeout: expected a finite positive number"
53
+ )
54
+ return cls(peer, run_id, seed, float(timeout))
55
+
56
+
57
+ class Relay:
58
+ """Holds the single active runner session."""
59
+
60
+ def __init__(self) -> None:
61
+ """Start with no session."""
62
+ self._session: Session | None = None
63
+
64
+ @property
65
+ def session(self) -> Session | None:
66
+ """The active session, if any."""
67
+ return self._session
68
+
69
+ def start(self, session: Session) -> None:
70
+ """Make `session` the active one."""
71
+ self._session = session
72
+
73
+ def end(self, session: Session) -> None:
74
+ """Forget `session` if it is still the active one."""
75
+ if self._session is session:
76
+ self._session = None
77
+
78
+
79
+ async def forward(session: Session | None, params: dict[str, Any]) -> Any:
80
+ """Ask the runner how to answer an upstream request.
81
+
82
+ Raises NoRunnerError, RunnerLostError, TimeoutError or RpcError.
83
+ """
84
+ if session is None:
85
+ raise NoRunnerError
86
+ try:
87
+ return await session.peer.call(
88
+ "upstream.request", params, timeout=session.upstream_response_timeout
89
+ )
90
+ except ChannelClosedError as exc:
91
+ raise RunnerLostError from exc
92
+
93
+
94
+ async def notify_done(session: Session | None, params: dict[str, Any]) -> None:
95
+ """Tell the runner how delivery of its answer ended; ignored if it is gone."""
96
+ if session is None:
97
+ return
98
+ with contextlib.suppress(ChannelClosedError):
99
+ await session.peer.notify("upstream.done", params)