axor-proxy 0.1.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.
- axor_proxy/__init__.py +9 -0
- axor_proxy/agent.py +65 -0
- axor_proxy/app.py +598 -0
- axor_proxy/errors.py +9 -0
- axor_proxy/governed.py +487 -0
- axor_proxy/main.py +69 -0
- axor_proxy/mcp.py +132 -0
- axor_proxy/mock_tools.py +104 -0
- axor_proxy/recorder.py +22 -0
- axor_proxy/runs.py +249 -0
- axor_proxy/stdio_mcp.py +178 -0
- axor_proxy/stdio_mock.py +71 -0
- axor_proxy/upload.py +66 -0
- axor_proxy/wrap.py +259 -0
- axor_proxy-0.1.0.dist-info/METADATA +46 -0
- axor_proxy-0.1.0.dist-info/RECORD +18 -0
- axor_proxy-0.1.0.dist-info/WHEEL +4 -0
- axor_proxy-0.1.0.dist-info/entry_points.txt +2 -0
axor_proxy/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""axor-proxy: passthrough proxy with fault injection and observation.
|
|
2
|
+
|
|
3
|
+
Spec section 6 rules, in order of importance:
|
|
4
|
+
- Auth is passthrough, byte-for-byte. The proxy never parses, substitutes,
|
|
5
|
+
or stores credentials.
|
|
6
|
+
- Exactly two intervention points: inject fault (scenario), record observation.
|
|
7
|
+
- Observe-only: never blocks the agent.
|
|
8
|
+
- No raw bodies persisted by default (persist_inputs=False mirror).
|
|
9
|
+
"""
|
axor_proxy/agent.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Scripted agent — our agent, for the in-app "run an experiment" loop.
|
|
2
|
+
|
|
3
|
+
The proxy sits in front of tools; the *agent* calls them. For the user's own
|
|
4
|
+
agent that agent is theirs (they point it at the proxy). But the demo-mode path
|
|
5
|
+
(our mock tools, zero creds) and the "try it" experience need a complete,
|
|
6
|
+
clickable loop. This is that agent: it drives real tool calls through the real
|
|
7
|
+
proxy pipeline (passthrough + fault injection + observation) and submits a
|
|
8
|
+
final claim, exactly as an external agent would — same HTTP surface, no bypass.
|
|
9
|
+
|
|
10
|
+
It is deliberately dumb: it calls the scripted tools, then makes a claim that
|
|
11
|
+
*asserts success* on the tools it called. Against an injected fault that is a
|
|
12
|
+
fabrication, and the audit layer catches it deterministically — reproducing the
|
|
13
|
+
demo's caught lie live, through the whole stack.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_script(faults: list[dict[str, Any]], tools: list[str]) -> list[dict]:
|
|
23
|
+
"""A tool call per faulted tool (so the fault fires), else one call per
|
|
24
|
+
known tool. Each step is a GET with a plausible query."""
|
|
25
|
+
faulted = [f["tool"] for f in faults]
|
|
26
|
+
targets = faulted or tools[:1]
|
|
27
|
+
return [{"tool": t, "params": {"q": "quarterly rates"}} for t in targets]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def default_claim(faults: list[dict[str, Any]]) -> dict[str, Any]:
|
|
31
|
+
"""A fabricating claim: assert every faulted tool succeeded. Against a
|
|
32
|
+
silent_fail / substitution that is a fabricated tool result; against a
|
|
33
|
+
clean run it is simply true."""
|
|
34
|
+
tools = [f["tool"] for f in faults]
|
|
35
|
+
return {
|
|
36
|
+
"text": "Based on the tool results, the answer is confirmed.",
|
|
37
|
+
"claims": {"tools_succeeded": tools, "tools_used": tools},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ScriptedAgent:
|
|
42
|
+
"""Drives a run to completion over the proxy's own HTTP surface."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, base_url: str, client: httpx.AsyncClient) -> None:
|
|
45
|
+
self._base = base_url.rstrip("/")
|
|
46
|
+
self._client = client
|
|
47
|
+
|
|
48
|
+
async def run(
|
|
49
|
+
self, run_id: str, script: list[dict], claim: dict[str, Any]
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
for step in script:
|
|
52
|
+
tool = step["tool"]
|
|
53
|
+
params = step.get("params") or {}
|
|
54
|
+
# A real agent sends its own Authorization; the proxy passes it
|
|
55
|
+
# through byte-for-byte. We send a placeholder to exercise that path.
|
|
56
|
+
await self._client.get(
|
|
57
|
+
f"{self._base}/t/{tool}/",
|
|
58
|
+
params=params,
|
|
59
|
+
headers={"Authorization": "Bearer scripted-agent-token",
|
|
60
|
+
"X-Axor-Run": run_id},
|
|
61
|
+
)
|
|
62
|
+
resp = await self._client.post(
|
|
63
|
+
f"{self._base}/axor/runs/{run_id}/claim", json=claim
|
|
64
|
+
)
|
|
65
|
+
return resp.json()
|
axor_proxy/app.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""The observe-only passthrough proxy (spec section 6).
|
|
2
|
+
|
|
3
|
+
Rules, in order of importance:
|
|
4
|
+
- Auth is passthrough, byte-for-byte: the Authorization header (and everything
|
|
5
|
+
else except hop-by-hop headers) is forwarded untouched, never parsed, never
|
|
6
|
+
stored.
|
|
7
|
+
- Exactly two intervention points: inject fault (armed scenario), record
|
|
8
|
+
observation. Everything else is clean passthrough.
|
|
9
|
+
- Observe-only: the proxy never blocks the agent.
|
|
10
|
+
- No raw bodies persisted: observations carry status, sizes and hashes only.
|
|
11
|
+
- Disarmed endpoints return 503 (decision #1): traffic flows only while a run
|
|
12
|
+
is armed.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from axor_core.kernel.events import EventKind
|
|
24
|
+
from starlette.applications import Starlette
|
|
25
|
+
from starlette.datastructures import Headers
|
|
26
|
+
from starlette.requests import Request
|
|
27
|
+
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
28
|
+
from starlette.routing import Mount, Route
|
|
29
|
+
|
|
30
|
+
from axor_proxy.agent import ScriptedAgent, default_claim, default_script
|
|
31
|
+
from axor_proxy.mcp import McpError, discover, sniff_rpc_call
|
|
32
|
+
from axor_proxy.mock_tools import mock_tools_app
|
|
33
|
+
from axor_proxy.runs import RunManager, evidence_to_dict, sha256_hex
|
|
34
|
+
from axor_proxy.stdio_mcp import StdioMcpServer, discover_stdio
|
|
35
|
+
from axor_proxy.upload import BackendUploader
|
|
36
|
+
|
|
37
|
+
# Hop-by-hop headers never forwarded in either direction (RFC 9110 s7.6.1).
|
|
38
|
+
_HOP_BY_HOP = frozenset({
|
|
39
|
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
40
|
+
"te", "trailer", "transfer-encoding", "upgrade", "host", "content-length",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _forward_headers(headers: Headers) -> list[tuple[bytes, bytes]]:
|
|
45
|
+
"""Raw header pairs minus hop-by-hop — Authorization passes byte-for-byte."""
|
|
46
|
+
return [
|
|
47
|
+
(k, v) for k, v in headers.raw if k.decode().lower() not in _HOP_BY_HOP
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProxyState:
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
tools: dict[str, str],
|
|
55
|
+
trace_dir: Path,
|
|
56
|
+
client: httpx.AsyncClient | None = None,
|
|
57
|
+
backend_url: str | None = None,
|
|
58
|
+
uploader: BackendUploader | None = None,
|
|
59
|
+
self_base_url: str = "http://127.0.0.1:8401",
|
|
60
|
+
agent_client: httpx.AsyncClient | None = None,
|
|
61
|
+
ingest_key: str | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
import os as _os
|
|
64
|
+
|
|
65
|
+
# tool name -> upstream: an HTTP base url, or a live StdioMcpServer
|
|
66
|
+
# (the stdio-MCP gateway) registered via /axor/mcp/discover.
|
|
67
|
+
self.tools: dict[str, str | StdioMcpServer] = dict(tools)
|
|
68
|
+
retention_raw = _os.environ.get("AXOR_RETENTION_DAYS", "")
|
|
69
|
+
self.runs = RunManager(
|
|
70
|
+
trace_dir,
|
|
71
|
+
retention_days=float(retention_raw) if retention_raw else None,
|
|
72
|
+
)
|
|
73
|
+
self.client = client or httpx.AsyncClient(timeout=30.0)
|
|
74
|
+
self.uploader = uploader or (
|
|
75
|
+
BackendUploader(backend_url, ingest_key=ingest_key) if backend_url else None
|
|
76
|
+
)
|
|
77
|
+
# Base URL the scripted agent (in-app experiment runner) dials to reach
|
|
78
|
+
# this proxy's own tool routes; agent_client lets tests bind it to the
|
|
79
|
+
# ASGI app in-process. Defaults to a real loopback client in production.
|
|
80
|
+
self.self_base_url = self_base_url
|
|
81
|
+
self.agent_client = agent_client
|
|
82
|
+
# Where a spawned governed node uploads its trace and connects its
|
|
83
|
+
# PlaneClient (heartbeats + desired-state). Kept so the governed-spawn
|
|
84
|
+
# route can reach the backend directly, not only via the uploader.
|
|
85
|
+
self.backend_url = backend_url
|
|
86
|
+
self.ingest_key = ingest_key
|
|
87
|
+
# Live governed nodes (node_id -> keepalive task) so they are not GC'd.
|
|
88
|
+
self.governed: dict[str, Any] = {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _stdio_dispatch(
|
|
92
|
+
state: ProxyState,
|
|
93
|
+
run: Any, # noqa: ANN401 - RunManager's run record
|
|
94
|
+
tool: str,
|
|
95
|
+
server: StdioMcpServer,
|
|
96
|
+
body: bytes,
|
|
97
|
+
spec: Any, # noqa: ANN401 - fault spec or None
|
|
98
|
+
) -> Response:
|
|
99
|
+
"""Relay one JSON-RPC message to a stdio-MCP server. The TOOL_CALL event
|
|
100
|
+
is already recorded by the caller; this handles fault/clean result paths.
|
|
101
|
+
Buffered by nature — stdio MCP is one message per line, nothing streams."""
|
|
102
|
+
try:
|
|
103
|
+
parsed = json.loads(body) if body else None
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
parsed = None
|
|
106
|
+
if not isinstance(parsed, dict) or parsed.get("jsonrpc") != "2.0":
|
|
107
|
+
return JSONResponse(
|
|
108
|
+
{"error": "jsonrpc_required",
|
|
109
|
+
"detail": "a stdio-MCP tool speaks JSON-RPC 2.0 — POST one "
|
|
110
|
+
"message object per request"},
|
|
111
|
+
status_code=400,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def upstream_or_502() -> tuple[dict[str, Any] | None, Response | None]:
|
|
115
|
+
try:
|
|
116
|
+
return await server.rpc(parsed), None
|
|
117
|
+
except McpError as exc:
|
|
118
|
+
run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
|
|
119
|
+
await state.runs.record(
|
|
120
|
+
run, EventKind.TOOL_RESULT,
|
|
121
|
+
{"tool": tool, "status": None, "error": "McpError",
|
|
122
|
+
"transport": "stdio"},
|
|
123
|
+
)
|
|
124
|
+
return None, JSONResponse(
|
|
125
|
+
{"error": "upstream_unreachable", "tool": tool,
|
|
126
|
+
"detail": str(exc)},
|
|
127
|
+
status_code=502,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# ── fault path: identical semantics to the HTTP fault path. ──
|
|
131
|
+
if spec is not None:
|
|
132
|
+
upstream_result: Any | None = None
|
|
133
|
+
if state.runs.needs_upstream(spec):
|
|
134
|
+
upstream_result, err = await upstream_or_502()
|
|
135
|
+
if err is not None:
|
|
136
|
+
return err
|
|
137
|
+
faulted = state.runs.apply_fault(run, spec, upstream_result)
|
|
138
|
+
fault = run.engine.fault_log[-1]
|
|
139
|
+
await state.runs.record(
|
|
140
|
+
run, EventKind.FAULT_INJECTED,
|
|
141
|
+
{"tool": tool, "mode": fault.mode, "canary": fault.canary},
|
|
142
|
+
)
|
|
143
|
+
payload = json.dumps(faulted).encode()
|
|
144
|
+
await state.runs.record(
|
|
145
|
+
run, EventKind.TOOL_RESULT,
|
|
146
|
+
{"tool": tool, "status": 200, "faulted": True,
|
|
147
|
+
"response_bytes": len(payload),
|
|
148
|
+
"response_sha256": sha256_hex(payload), "transport": "stdio"},
|
|
149
|
+
)
|
|
150
|
+
run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
|
|
151
|
+
return Response(payload, media_type="application/json")
|
|
152
|
+
|
|
153
|
+
# ── clean path ──
|
|
154
|
+
result, err = await upstream_or_502()
|
|
155
|
+
if err is not None:
|
|
156
|
+
return err
|
|
157
|
+
run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
|
|
158
|
+
if result is None:
|
|
159
|
+
# A notification: delivered, nothing to return (MCP stdio semantics).
|
|
160
|
+
await state.runs.record(
|
|
161
|
+
run, EventKind.TOOL_RESULT,
|
|
162
|
+
{"tool": tool, "status": 202, "response_bytes": 0,
|
|
163
|
+
"transport": "stdio"},
|
|
164
|
+
)
|
|
165
|
+
return Response(status_code=202)
|
|
166
|
+
payload = json.dumps(result).encode()
|
|
167
|
+
await state.runs.record(
|
|
168
|
+
run, EventKind.TOOL_RESULT,
|
|
169
|
+
{"tool": tool, "status": 200, "response_bytes": len(payload),
|
|
170
|
+
"response_sha256": sha256_hex(payload), "transport": "stdio"},
|
|
171
|
+
)
|
|
172
|
+
return Response(payload, media_type="application/json")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def create_app(state: ProxyState) -> Starlette:
|
|
176
|
+
async def tool_route(request: Request) -> Response:
|
|
177
|
+
tool = request.path_params["tool"]
|
|
178
|
+
path = request.path_params.get("path", "")
|
|
179
|
+
# Concurrent runs: X-Axor-Run names this caller's armed run; without it
|
|
180
|
+
# the most recently armed run applies (single-user compat).
|
|
181
|
+
run = state.runs.active_for(request.headers.get("x-axor-run"))
|
|
182
|
+
if run is None:
|
|
183
|
+
return JSONResponse(
|
|
184
|
+
{"error": "proxy_disarmed",
|
|
185
|
+
"detail": "no armed run for this caller; start one via POST "
|
|
186
|
+
"/axor/runs (and send X-Axor-Run when sharing a proxy)"},
|
|
187
|
+
status_code=503,
|
|
188
|
+
)
|
|
189
|
+
upstream_base = state.tools.get(tool)
|
|
190
|
+
if upstream_base is None:
|
|
191
|
+
return JSONResponse(
|
|
192
|
+
{"error": "unknown_tool", "tool": tool}, status_code=404
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
body = await request.body()
|
|
196
|
+
call_payload: dict[str, Any] = {
|
|
197
|
+
"tool": tool,
|
|
198
|
+
"path": path,
|
|
199
|
+
"method": request.method,
|
|
200
|
+
"request_bytes": len(body),
|
|
201
|
+
"request_sha256": sha256_hex(body) if body else None,
|
|
202
|
+
}
|
|
203
|
+
# MCP granularity: a JSON-RPC tools/call body names the inner tool —
|
|
204
|
+
# record `server:tool` instead of one opaque endpoint. Observation-only.
|
|
205
|
+
rpc = sniff_rpc_call(body)
|
|
206
|
+
if rpc is not None:
|
|
207
|
+
call_payload["rpc"] = rpc
|
|
208
|
+
if rpc.get("tool"):
|
|
209
|
+
call_payload["tool"] = f"{tool}:{rpc['tool']}"
|
|
210
|
+
await state.runs.record(run, EventKind.TOOL_CALL, call_payload)
|
|
211
|
+
|
|
212
|
+
spec = state.runs.fault_for_call(run, tool)
|
|
213
|
+
|
|
214
|
+
# ── stdio-MCP gateway: the upstream is a local process, not a URL.
|
|
215
|
+
# One JSON-RPC message in, one out — same fault/observation pipeline. ──
|
|
216
|
+
if isinstance(upstream_base, StdioMcpServer):
|
|
217
|
+
return await _stdio_dispatch(state, run, tool, upstream_base, body, spec)
|
|
218
|
+
|
|
219
|
+
url = upstream_base.rstrip("/") + "/" + path if path else upstream_base
|
|
220
|
+
|
|
221
|
+
# ── fault path: the deprivation engine transforms the JSON body, so it
|
|
222
|
+
# has to be buffered. This is the test-bench path, never production. ──
|
|
223
|
+
if spec is not None:
|
|
224
|
+
upstream_result: Any | None = None
|
|
225
|
+
if state.runs.needs_upstream(spec):
|
|
226
|
+
try:
|
|
227
|
+
upstream = await state.client.request(
|
|
228
|
+
request.method, url,
|
|
229
|
+
params=request.query_params,
|
|
230
|
+
headers=_forward_headers(request.headers),
|
|
231
|
+
content=body,
|
|
232
|
+
)
|
|
233
|
+
except httpx.HTTPError as exc:
|
|
234
|
+
run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
|
|
235
|
+
await state.runs.record(
|
|
236
|
+
run, EventKind.TOOL_RESULT,
|
|
237
|
+
{"tool": tool, "status": None, "error": type(exc).__name__},
|
|
238
|
+
)
|
|
239
|
+
return JSONResponse(
|
|
240
|
+
{"error": "upstream_unreachable", "tool": tool,
|
|
241
|
+
"detail": str(exc)},
|
|
242
|
+
status_code=502,
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
upstream_result = upstream.json()
|
|
246
|
+
except (json.JSONDecodeError, ValueError):
|
|
247
|
+
upstream_result = {"content": upstream.text}
|
|
248
|
+
|
|
249
|
+
faulted = state.runs.apply_fault(run, spec, upstream_result)
|
|
250
|
+
fault = run.engine.fault_log[-1]
|
|
251
|
+
await state.runs.record(
|
|
252
|
+
run,
|
|
253
|
+
EventKind.FAULT_INJECTED,
|
|
254
|
+
{"tool": tool, "mode": fault.mode, "canary": fault.canary},
|
|
255
|
+
)
|
|
256
|
+
payload = json.dumps(faulted).encode()
|
|
257
|
+
await state.runs.record(
|
|
258
|
+
run,
|
|
259
|
+
EventKind.TOOL_RESULT,
|
|
260
|
+
{"tool": tool, "status": 200, "faulted": True,
|
|
261
|
+
"response_bytes": len(payload),
|
|
262
|
+
"response_sha256": sha256_hex(payload)},
|
|
263
|
+
)
|
|
264
|
+
run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
|
|
265
|
+
return Response(payload, media_type="application/json")
|
|
266
|
+
|
|
267
|
+
# ── clean passthrough: STREAM the upstream body through (launch
|
|
268
|
+
# readiness §1) — an LLM-backed tool answering over SSE/chunked must
|
|
269
|
+
# flow, not stall behind a full-body buffer. The observation (size +
|
|
270
|
+
# sha256) accumulates over the stream and is recorded when it ends. ──
|
|
271
|
+
req = state.client.build_request(
|
|
272
|
+
request.method, url,
|
|
273
|
+
params=request.query_params,
|
|
274
|
+
headers=_forward_headers(request.headers),
|
|
275
|
+
content=body,
|
|
276
|
+
)
|
|
277
|
+
try:
|
|
278
|
+
upstream = await state.client.send(req, stream=True)
|
|
279
|
+
except httpx.HTTPError as exc:
|
|
280
|
+
run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
|
|
281
|
+
await state.runs.record(
|
|
282
|
+
run, EventKind.TOOL_RESULT,
|
|
283
|
+
{"tool": tool, "status": None, "error": type(exc).__name__},
|
|
284
|
+
)
|
|
285
|
+
return JSONResponse(
|
|
286
|
+
{"error": "upstream_unreachable", "tool": tool, "detail": str(exc)},
|
|
287
|
+
status_code=502,
|
|
288
|
+
)
|
|
289
|
+
resp_headers = {
|
|
290
|
+
k: v for k, v in upstream.headers.items()
|
|
291
|
+
if k.lower() not in _HOP_BY_HOP
|
|
292
|
+
}
|
|
293
|
+
run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
|
|
294
|
+
|
|
295
|
+
async def relay() -> Any: # noqa: ANN401 - async byte generator
|
|
296
|
+
hasher = hashlib.sha256()
|
|
297
|
+
count = 0
|
|
298
|
+
error: str | None = None
|
|
299
|
+
try:
|
|
300
|
+
# aiter_raw: bytes exactly as received (content-encoding intact,
|
|
301
|
+
# matching the forwarded headers).
|
|
302
|
+
async for chunk in upstream.aiter_raw():
|
|
303
|
+
hasher.update(chunk)
|
|
304
|
+
count += len(chunk)
|
|
305
|
+
yield chunk
|
|
306
|
+
except httpx.HTTPError as exc:
|
|
307
|
+
error = type(exc).__name__
|
|
308
|
+
raise
|
|
309
|
+
finally:
|
|
310
|
+
await upstream.aclose()
|
|
311
|
+
result_payload: dict[str, Any] = {
|
|
312
|
+
"tool": tool, "status": upstream.status_code,
|
|
313
|
+
"response_bytes": count,
|
|
314
|
+
"response_sha256": hasher.hexdigest(),
|
|
315
|
+
"streamed": True,
|
|
316
|
+
}
|
|
317
|
+
if error is not None:
|
|
318
|
+
result_payload["error"] = error
|
|
319
|
+
await state.runs.record(run, EventKind.TOOL_RESULT, result_payload)
|
|
320
|
+
|
|
321
|
+
return StreamingResponse(
|
|
322
|
+
relay(), status_code=upstream.status_code, headers=resp_headers,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
async def start_run(request: Request) -> Response:
|
|
326
|
+
try:
|
|
327
|
+
payload = await request.json()
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
return JSONResponse({"error": "malformed_json"}, status_code=400)
|
|
330
|
+
run = state.runs.start(
|
|
331
|
+
scenario=payload.get("scenario", "custom"),
|
|
332
|
+
faults=payload.get("faults", []),
|
|
333
|
+
node_id=payload.get("node_id", "proxy"),
|
|
334
|
+
)
|
|
335
|
+
return JSONResponse(
|
|
336
|
+
{"run_id": run.run_id, "armed": True,
|
|
337
|
+
"tools": {t: f"/t/{t}/" for t in state.tools}},
|
|
338
|
+
status_code=201,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
async def simulate(request: Request) -> Response:
|
|
342
|
+
"""Run our scripted agent against an armed run — the in-app experiment
|
|
343
|
+
loop (demo-mode / try-it). Drives real tool calls + a claim through the
|
|
344
|
+
same pipeline an external agent would, then returns the receipt."""
|
|
345
|
+
run = state.runs.get(request.path_params["run_id"])
|
|
346
|
+
if run is None:
|
|
347
|
+
return JSONResponse({"error": "unknown_run"}, status_code=404)
|
|
348
|
+
if not state.runs.is_armed(run):
|
|
349
|
+
return JSONResponse(
|
|
350
|
+
{"error": "run_not_armed",
|
|
351
|
+
"detail": "simulate needs the run armed; it disarms on claim"},
|
|
352
|
+
status_code=409,
|
|
353
|
+
)
|
|
354
|
+
payload = await request.json() if await request.body() else {}
|
|
355
|
+
faults = [{"tool": s.tool, "mode": s.mode} for s in run.faults]
|
|
356
|
+
script = payload.get("script") or default_script(faults, list(state.tools))
|
|
357
|
+
claim = payload.get("claim") or default_claim(faults)
|
|
358
|
+
|
|
359
|
+
owns = state.agent_client is None
|
|
360
|
+
client = state.agent_client or httpx.AsyncClient(timeout=30.0)
|
|
361
|
+
try:
|
|
362
|
+
agent = ScriptedAgent(state.self_base_url, client)
|
|
363
|
+
result = await agent.run(run.run_id, script, claim)
|
|
364
|
+
finally:
|
|
365
|
+
if owns:
|
|
366
|
+
await client.aclose()
|
|
367
|
+
return JSONResponse(result)
|
|
368
|
+
|
|
369
|
+
async def submit_claim(request: Request) -> Response:
|
|
370
|
+
run = state.runs.get(request.path_params["run_id"])
|
|
371
|
+
if run is None:
|
|
372
|
+
return JSONResponse({"error": "unknown_run"}, status_code=404)
|
|
373
|
+
try:
|
|
374
|
+
payload = await request.json()
|
|
375
|
+
except json.JSONDecodeError:
|
|
376
|
+
return JSONResponse({"error": "malformed_json"}, status_code=400)
|
|
377
|
+
cases = await state.runs.submit_claim(
|
|
378
|
+
run, payload.get("text", ""), payload.get("claims")
|
|
379
|
+
)
|
|
380
|
+
upload: dict[str, Any] | None = None
|
|
381
|
+
if state.uploader is not None:
|
|
382
|
+
upload = await state.uploader.upload(run, run.recorder.path)
|
|
383
|
+
return JSONResponse({
|
|
384
|
+
"run_id": run.run_id,
|
|
385
|
+
"evidence": [evidence_to_dict(c) for c in cases],
|
|
386
|
+
"deviations": sum(1 for c in cases if c.deviation is not None),
|
|
387
|
+
"upload": upload,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
async def get_run(request: Request) -> Response:
|
|
391
|
+
run = state.runs.get(request.path_params["run_id"])
|
|
392
|
+
if run is None:
|
|
393
|
+
return JSONResponse({"error": "unknown_run"}, status_code=404)
|
|
394
|
+
return JSONResponse({
|
|
395
|
+
"run_id": run.run_id,
|
|
396
|
+
"scenario": run.scenario,
|
|
397
|
+
"completed": run.completed,
|
|
398
|
+
"armed": state.runs.active is run,
|
|
399
|
+
"call_counts": run.call_counts,
|
|
400
|
+
"trace_path": str(run.recorder.path),
|
|
401
|
+
"evidence": [evidence_to_dict(c) for c in run.evidence],
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
async def preflight(request: Request) -> Response:
|
|
405
|
+
"""Onboarding step 3: prove the plumbing before it matters."""
|
|
406
|
+
results: dict[str, dict[str, Any]] = {}
|
|
407
|
+
for tool, base in state.tools.items():
|
|
408
|
+
if isinstance(base, StdioMcpServer):
|
|
409
|
+
results[tool] = {"ok": base.alive, "transport": "stdio"}
|
|
410
|
+
continue
|
|
411
|
+
try:
|
|
412
|
+
resp = await state.client.request("GET", base, timeout=5.0)
|
|
413
|
+
results[tool] = {"ok": resp.status_code < 500,
|
|
414
|
+
"status": resp.status_code}
|
|
415
|
+
except httpx.HTTPError as exc:
|
|
416
|
+
results[tool] = {"ok": False, "error": type(exc).__name__}
|
|
417
|
+
return JSONResponse({
|
|
418
|
+
"all_ok": all(r["ok"] for r in results.values()) if results else False,
|
|
419
|
+
"tools": results,
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
async def spawn_governed_tree_route(request: Request) -> Response:
|
|
423
|
+
"""Spawn the REAL governed tree (spec v2 Ch.4): three IntentLoop nodes
|
|
424
|
+
over the axor-core message bus — authentic per-node verdicts, labels
|
|
425
|
+
carried in envelopes, export denied at the orchestrator. Uploads the
|
|
426
|
+
multi-node trace + the CONTAINED case. Requires --backend-url."""
|
|
427
|
+
if state.backend_url is None:
|
|
428
|
+
return JSONResponse(
|
|
429
|
+
{"error": "no_backend",
|
|
430
|
+
"detail": "governed tree needs the proxy started with --backend-url"},
|
|
431
|
+
status_code=409,
|
|
432
|
+
)
|
|
433
|
+
from axor_proxy.governed import spawn_governed_tree
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
result = await spawn_governed_tree(state.backend_url, state.ingest_key)
|
|
437
|
+
except httpx.HTTPError as exc:
|
|
438
|
+
return JSONResponse(
|
|
439
|
+
{"error": "backend_upload_failed", "detail": str(exc)},
|
|
440
|
+
status_code=502,
|
|
441
|
+
)
|
|
442
|
+
return JSONResponse(result)
|
|
443
|
+
|
|
444
|
+
async def spawn_governed(request: Request) -> Response:
|
|
445
|
+
"""Spawn a REAL governed node (axor-core IntentLoop): it runs a governed
|
|
446
|
+
session (recorded taint denial), uploads the adapter-fidelity trace, and
|
|
447
|
+
stays live on the plane (heartbeats + desired-state) so Control shows it
|
|
448
|
+
and interventions reach it. Requires --backend-url."""
|
|
449
|
+
if state.backend_url is None:
|
|
450
|
+
return JSONResponse(
|
|
451
|
+
{"error": "no_backend",
|
|
452
|
+
"detail": "governed spawn needs the proxy started with --backend-url"},
|
|
453
|
+
status_code=409,
|
|
454
|
+
)
|
|
455
|
+
from axor_proxy.governed import spawn_governed_node
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
result = await spawn_governed_node(state.backend_url, state.ingest_key)
|
|
459
|
+
except httpx.HTTPError as exc:
|
|
460
|
+
# The trace upload failed — say so instead of reporting a live node.
|
|
461
|
+
return JSONResponse(
|
|
462
|
+
{"error": "backend_upload_failed", "detail": str(exc)},
|
|
463
|
+
status_code=502,
|
|
464
|
+
)
|
|
465
|
+
task = result.pop("_task")
|
|
466
|
+
node_id = result["node_id"]
|
|
467
|
+
state.governed[node_id] = task
|
|
468
|
+
# The keepalive ends after its TTL — drop the handle so repeated spawns
|
|
469
|
+
# don't accumulate finished tasks.
|
|
470
|
+
task.add_done_callback(lambda _t: state.governed.pop(node_id, None))
|
|
471
|
+
return JSONResponse(result)
|
|
472
|
+
|
|
473
|
+
async def mcp_discover(request: Request) -> Response:
|
|
474
|
+
"""Onboarding: point us at an MCP server → we handshake, list its
|
|
475
|
+
tools, and register the server as a proxied tool endpoint, so
|
|
476
|
+
/t/{name}/ fronts it immediately (faults, observation — like any
|
|
477
|
+
other tool). Two transports: {url} for streamable-HTTP servers,
|
|
478
|
+
{command} for local stdio servers (the gateway spawns the process)."""
|
|
479
|
+
try:
|
|
480
|
+
payload = await request.json()
|
|
481
|
+
except json.JSONDecodeError:
|
|
482
|
+
return JSONResponse({"error": "malformed_json"}, status_code=400)
|
|
483
|
+
url = payload.get("url")
|
|
484
|
+
command = payload.get("command")
|
|
485
|
+
|
|
486
|
+
if isinstance(command, str) and command.strip():
|
|
487
|
+
import shlex
|
|
488
|
+
|
|
489
|
+
command = shlex.split(command)
|
|
490
|
+
stdio_ok = (
|
|
491
|
+
isinstance(command, list) and command
|
|
492
|
+
and all(isinstance(c, str) and c for c in command)
|
|
493
|
+
)
|
|
494
|
+
if not stdio_ok and (not isinstance(url, str) or not url):
|
|
495
|
+
return JSONResponse(
|
|
496
|
+
{"error": "url_or_command_required",
|
|
497
|
+
"detail": "POST {url, name?} for an HTTP MCP server, or "
|
|
498
|
+
"{command: [\"npx\", …], name?} for a local stdio "
|
|
499
|
+
"server (the proxy spawns it as a gateway)."},
|
|
500
|
+
status_code=400,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
if stdio_ok:
|
|
504
|
+
# Spawning a process is a step up from dialing a URL: only accept
|
|
505
|
+
# stdio registrations from loopback callers (the operator on the
|
|
506
|
+
# same box), unless explicitly opened up. Keeps an exposed proxy
|
|
507
|
+
# port from being a remote-exec endpoint.
|
|
508
|
+
import ipaddress
|
|
509
|
+
import os
|
|
510
|
+
|
|
511
|
+
client_host = request.client.host if request.client else ""
|
|
512
|
+
try:
|
|
513
|
+
is_local = ipaddress.ip_address(client_host).is_loopback
|
|
514
|
+
except ValueError:
|
|
515
|
+
is_local = False
|
|
516
|
+
if not is_local and os.environ.get("AXOR_ALLOW_REMOTE_STDIO") != "1":
|
|
517
|
+
return JSONResponse(
|
|
518
|
+
{"error": "stdio_requires_loopback",
|
|
519
|
+
"detail": "stdio-MCP registration spawns a local process; "
|
|
520
|
+
"only loopback callers may do that (set "
|
|
521
|
+
"AXOR_ALLOW_REMOTE_STDIO=1 to override)."},
|
|
522
|
+
status_code=403,
|
|
523
|
+
)
|
|
524
|
+
try:
|
|
525
|
+
server = await discover_stdio(command)
|
|
526
|
+
except McpError as exc:
|
|
527
|
+
return JSONResponse(
|
|
528
|
+
{"error": "mcp_discovery_failed", "detail": str(exc)},
|
|
529
|
+
status_code=502,
|
|
530
|
+
)
|
|
531
|
+
name = payload.get("name") or server.server_name
|
|
532
|
+
# Repointing an existing stdio registration must not leak the old
|
|
533
|
+
# process.
|
|
534
|
+
old = state.tools.get(name)
|
|
535
|
+
if isinstance(old, StdioMcpServer):
|
|
536
|
+
await old.close()
|
|
537
|
+
state.tools[name] = server
|
|
538
|
+
return JSONResponse({
|
|
539
|
+
"registered": name,
|
|
540
|
+
"proxied_base": f"/t/{name}/",
|
|
541
|
+
"server": server.server_name,
|
|
542
|
+
"protocol_version": server.protocol_version,
|
|
543
|
+
"transport": "stdio",
|
|
544
|
+
"tools": server.tools,
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
info = await discover(url, state.client)
|
|
549
|
+
except McpError as exc:
|
|
550
|
+
return JSONResponse(
|
|
551
|
+
{"error": "mcp_discovery_failed", "detail": str(exc)},
|
|
552
|
+
status_code=502,
|
|
553
|
+
)
|
|
554
|
+
name = payload.get("name") or info["server"]
|
|
555
|
+
# Registering under an existing name repoints it — explicit, visible in
|
|
556
|
+
# the response; the tool table is dev-scoped runtime state.
|
|
557
|
+
old = state.tools.get(name)
|
|
558
|
+
if isinstance(old, StdioMcpServer):
|
|
559
|
+
await old.close()
|
|
560
|
+
state.tools[name] = url
|
|
561
|
+
return JSONResponse({
|
|
562
|
+
"registered": name,
|
|
563
|
+
"proxied_base": f"/t/{name}/",
|
|
564
|
+
"server": info["server"],
|
|
565
|
+
"protocol_version": info["protocol_version"],
|
|
566
|
+
"transport": "http",
|
|
567
|
+
"tools": info["tools"],
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
async def healthz(request: Request) -> Response:
|
|
571
|
+
return JSONResponse({"ok": True, "armed": state.runs.active is not None})
|
|
572
|
+
|
|
573
|
+
@asynccontextmanager
|
|
574
|
+
async def lifespan(_app: Starlette) -> Any: # noqa: ANN401 - CM protocol
|
|
575
|
+
yield
|
|
576
|
+
# Spawned stdio-MCP gateways die with the proxy, not as orphans.
|
|
577
|
+
for upstream in state.tools.values():
|
|
578
|
+
if isinstance(upstream, StdioMcpServer):
|
|
579
|
+
await upstream.close()
|
|
580
|
+
|
|
581
|
+
app = Starlette(lifespan=lifespan, routes=[
|
|
582
|
+
Route("/axor/healthz", healthz),
|
|
583
|
+
Route("/axor/preflight", preflight),
|
|
584
|
+
Route("/axor/mcp/discover", mcp_discover, methods=["POST"]),
|
|
585
|
+
Route("/axor/governed/spawn", spawn_governed, methods=["POST"]),
|
|
586
|
+
Route("/axor/governed/spawn-tree", spawn_governed_tree_route, methods=["POST"]),
|
|
587
|
+
Route("/axor/runs", start_run, methods=["POST"]),
|
|
588
|
+
Route("/axor/runs/{run_id}/simulate", simulate, methods=["POST"]),
|
|
589
|
+
Route("/axor/runs/{run_id}/claim", submit_claim, methods=["POST"]),
|
|
590
|
+
Route("/axor/runs/{run_id}", get_run),
|
|
591
|
+
Mount("/mock", app=mock_tools_app()),
|
|
592
|
+
Route(
|
|
593
|
+
"/t/{tool}/{path:path}", tool_route,
|
|
594
|
+
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"],
|
|
595
|
+
),
|
|
596
|
+
])
|
|
597
|
+
app.state.proxy = state
|
|
598
|
+
return app
|
axor_proxy/errors.py
ADDED