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.
- canarywire/__init__.py +3 -0
- canarywire/__main__.py +5 -0
- canarywire/canaries.py +15 -0
- canarywire/capture/__init__.py +0 -0
- canarywire/capture/app.py +344 -0
- canarywire/capture/daemon.py +197 -0
- canarywire/capture/recorder.py +24 -0
- canarywire/capture/relay.py +99 -0
- canarywire/cli.py +188 -0
- canarywire/config.py +592 -0
- canarywire/loopback.py +19 -0
- canarywire/py.typed +0 -0
- canarywire/report/__init__.py +222 -0
- canarywire/report/context.py +46 -0
- canarywire/report/findings.py +146 -0
- canarywire/report/mustache.py +259 -0
- canarywire/report/outputs.py +122 -0
- canarywire/report/samples.py +216 -0
- canarywire/report/templates/junit.xml.mustache +31 -0
- canarywire/report/templates/markdown.md.mustache +72 -0
- canarywire/report/view.py +293 -0
- canarywire/rpc.py +191 -0
- canarywire/runner/__init__.py +0 -0
- canarywire/runner/attacks.py +385 -0
- canarywire/runner/catalog.py +385 -0
- canarywire/runner/consistency.py +75 -0
- canarywire/runner/fault.py +274 -0
- canarywire/runner/fragmentation.py +296 -0
- canarywire/runner/hooks.py +73 -0
- canarywire/runner/jsonpath.py +97 -0
- canarywire/runner/messages.py +83 -0
- canarywire/runner/neutral.py +155 -0
- canarywire/runner/prepare.py +284 -0
- canarywire/runner/protocols/__init__.py +40 -0
- canarywire/runner/protocols/anthropic.py +218 -0
- canarywire/runner/protocols/openai.py +186 -0
- canarywire/runner/restore.py +134 -0
- canarywire/runner/runner.py +236 -0
- canarywire/runner/scan.py +181 -0
- canarywire/runner/session.py +151 -0
- canarywire/runner/splits.py +96 -0
- canarywire/templates/__init__.py +0 -0
- canarywire/templates/default.yaml +13 -0
- canarywire/templates/multi-turn.yaml +16 -0
- canarywire/templates/tool-calls.yaml +34 -0
- canarywire/values/__init__.py +0 -0
- canarywire/values/builtins.py +202 -0
- canarywire/values/checksums.py +91 -0
- canarywire/values/errors.py +5 -0
- canarywire/values/formats.py +34 -0
- canarywire/values/regex.py +300 -0
- canarywire/values/types.py +269 -0
- canarywire-0.1.0a1.dist-info/METADATA +134 -0
- canarywire-0.1.0a1.dist-info/RECORD +57 -0
- canarywire-0.1.0a1.dist-info/WHEEL +4 -0
- canarywire-0.1.0a1.dist-info/entry_points.txt +2 -0
- canarywire-0.1.0a1.dist-info/licenses/LICENSE +202 -0
canarywire/__init__.py
ADDED
canarywire/__main__.py
ADDED
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)
|