relay-backend 0.0.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.
- relay_agent/__init__.py +7 -0
- relay_agent/__main__.py +3 -0
- relay_agent/app.py +216 -0
- relay_agent/cli.py +47 -0
- relay_agent/executor.py +32 -0
- relay_agent/files.py +88 -0
- relay_agent/git_source.py +128 -0
- relay_agent/git_status.py +83 -0
- relay_agent/integration.py +48 -0
- relay_agent/openapi.py +117 -0
- relay_agent/security.py +60 -0
- relay_agent/static/assets/index-D9Mabhh3.css +1 -0
- relay_agent/static/assets/index-bkUH1ypy.js +15 -0
- relay_agent/static/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- relay_agent/static/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- relay_agent/static/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- relay_agent/static/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- relay_agent/static/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- relay_agent/static/assets/relay-key.webp +0 -0
- relay_agent/static/assets/relay-key.webp.json +4 -0
- relay_agent/static/assets/relay-signal.webp +0 -0
- relay_agent/static/assets/relay-signal.webp.json +4 -0
- relay_agent/static/brand/identity.css +13 -0
- relay_agent/static/brand/identity.html +21 -0
- relay_agent/static/brand/relay-icon.svg +1 -0
- relay_agent/static/brand/relay-logo-black.svg +1 -0
- relay_agent/static/brand/relay-logo-light.svg +1 -0
- relay_agent/static/brand/relay-logo-white.svg +1 -0
- relay_agent/static/brand/relay-logo.svg +1 -0
- relay_agent/static/brand/relay-symbol.svg +1 -0
- relay_agent/static/brand/relay-wordmark.svg +1 -0
- relay_agent/static/index.html +4 -0
- relay_agent/static/relay-mark.svg +1 -0
- relay_agent/workspace.py +100 -0
- relay_backend-0.0.1.dist-info/METADATA +70 -0
- relay_backend-0.0.1.dist-info/RECORD +41 -0
- relay_backend-0.0.1.dist-info/WHEEL +5 -0
- relay_backend-0.0.1.dist-info/entry_points.txt +2 -0
- relay_backend-0.0.1.dist-info/licenses/LICENSE +202 -0
- relay_backend-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +169 -0
- relay_backend-0.0.1.dist-info/top_level.txt +1 -0
relay_agent/__init__.py
ADDED
relay_agent/__main__.py
ADDED
relay_agent/app.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import secrets
|
|
3
|
+
import ipaddress
|
|
4
|
+
import posixpath
|
|
5
|
+
import asyncio
|
|
6
|
+
from html import escape
|
|
7
|
+
from urllib.parse import unquote
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
|
14
|
+
from fastapi.responses import JSONResponse, HTMLResponse
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from .executor import send
|
|
19
|
+
from .openapi import SpecError, normalize
|
|
20
|
+
from .security import MAX_REQUEST, MAX_RESPONSE, Settings, safe_headers, validate_path
|
|
21
|
+
from .workspace import Workspace
|
|
22
|
+
from .git_source import GitSource
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExecuteRequest(BaseModel):
|
|
26
|
+
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
|
|
27
|
+
path: str = Field(max_length=8192)
|
|
28
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
29
|
+
query: dict[str, str] = Field(default_factory=dict)
|
|
30
|
+
body_mode: Literal["none", "json", "text", "form"] = "none"
|
|
31
|
+
body: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EndpointUpdate(BaseModel):
|
|
35
|
+
endpoint_id: str = Field(max_length=8192)
|
|
36
|
+
fingerprint: str = Field(min_length=64, max_length=64)
|
|
37
|
+
progress: Literal["not_started", "in_progress", "done", "needs_fixing"] | None = None
|
|
38
|
+
note: str | None = Field(default=None, max_length=4000)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_app(settings: Settings | None = None, transport=None, static_dir: Path | None = None, db_path: str | Path = ":memory:", project: Path | None = None, native_host: FastAPI | None = None):
|
|
42
|
+
settings = settings or Settings()
|
|
43
|
+
token = secrets.token_urlsafe(32)
|
|
44
|
+
|
|
45
|
+
@asynccontextmanager
|
|
46
|
+
async def lifespan(app):
|
|
47
|
+
workspace = Workspace(db_path, project=project)
|
|
48
|
+
app.state.workspace = workspace
|
|
49
|
+
app.state.git_source = GitSource(workspace.shared.root if workspace.shared else None)
|
|
50
|
+
app.state.snapshot = None
|
|
51
|
+
app.state.raw_spec = None
|
|
52
|
+
try:
|
|
53
|
+
async with httpx.AsyncClient(timeout=20, follow_redirects=False, trust_env=False, transport=transport) as client:
|
|
54
|
+
app.state.client = client
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
workspace.close()
|
|
58
|
+
|
|
59
|
+
app = FastAPI(title="Relay Local Companion", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
|
|
60
|
+
app.state.snapshot = None
|
|
61
|
+
app.state.raw_spec = None
|
|
62
|
+
|
|
63
|
+
@app.middleware("http")
|
|
64
|
+
async def guard(request: Request, call_next):
|
|
65
|
+
expected_origin = f"{request.url.scheme}://{request.url.netloc}" if native_host else settings.origin
|
|
66
|
+
if native_host:
|
|
67
|
+
def local(host):
|
|
68
|
+
try:
|
|
69
|
+
return ipaddress.ip_address(host).is_loopback
|
|
70
|
+
except ValueError:
|
|
71
|
+
return host == "localhost"
|
|
72
|
+
if not request.client or not local(request.client.host) or not local(request.url.hostname or ""):
|
|
73
|
+
return JSONResponse({"detail": "Relay is available only on the developer's local machine."}, 403)
|
|
74
|
+
elif request.headers.get("host") != f"127.0.0.1:{settings.port}":
|
|
75
|
+
return JSONResponse({"detail": f"Open Relay at {settings.origin}; this host is not allowed."}, 403)
|
|
76
|
+
origin = request.headers.get("origin")
|
|
77
|
+
if origin and origin != expected_origin:
|
|
78
|
+
return JSONResponse({"detail": "Untrusted browser origin."}, 403)
|
|
79
|
+
if request.headers.get("sec-fetch-site") not in {None, "none", "same-origin"}:
|
|
80
|
+
return JSONResponse({"detail": "Cross-origin access to the local runner is forbidden."}, 403)
|
|
81
|
+
if request.method not in {"GET", "HEAD"}:
|
|
82
|
+
if origin != expected_origin or not secrets.compare_digest(request.headers.get("x-relay-csrf", "").encode(), token.encode()):
|
|
83
|
+
return JSONResponse({"detail": "Local session expired or CSRF check failed. Reload Relay."}, 403)
|
|
84
|
+
# Read the stream with a hard cap before Pydantic allocates the request body.
|
|
85
|
+
body = bytearray()
|
|
86
|
+
async for chunk in request.stream():
|
|
87
|
+
body.extend(chunk)
|
|
88
|
+
if len(body) > MAX_REQUEST:
|
|
89
|
+
return JSONResponse({"detail": "Request exceeds the 1 MiB limit."}, 413)
|
|
90
|
+
request._body = bytes(body)
|
|
91
|
+
response = await call_next(request)
|
|
92
|
+
response.headers.update({"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
|
|
93
|
+
"Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY",
|
|
94
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'"})
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
def target_base(request: Request):
|
|
98
|
+
if not native_host:
|
|
99
|
+
return settings.base_url.rstrip("/")
|
|
100
|
+
root = request.scope.get("root_path", "").removesuffix("/relay")
|
|
101
|
+
return f"{request.url.scheme}://{request.url.netloc}{root}"
|
|
102
|
+
|
|
103
|
+
@app.get("/__relay/health")
|
|
104
|
+
def health(request: Request):
|
|
105
|
+
return {"status": "ok", "mode": "local", "version": "0.0.1", "csrf_token": token,
|
|
106
|
+
"base_url": target_base(request), "spec_url": target_base(request) + (native_host.openapi_url or "") if native_host else settings.target(settings.spec_path)}
|
|
107
|
+
|
|
108
|
+
@app.post("/__relay/sources/inspect")
|
|
109
|
+
async def inspect():
|
|
110
|
+
try:
|
|
111
|
+
if native_host:
|
|
112
|
+
raw = native_host.openapi()
|
|
113
|
+
if len(json.dumps(raw).encode()) > MAX_RESPONSE:
|
|
114
|
+
raise HTTPException(413, "OpenAPI document exceeds the 2 MiB limit.")
|
|
115
|
+
else:
|
|
116
|
+
result = await send(app.state.client, "GET", settings.target(settings.spec_path), limit=MAX_RESPONSE)
|
|
117
|
+
if result["status"] != 200:
|
|
118
|
+
raise HTTPException(502, f"OpenAPI returned HTTP {result['status']}. Check the configured spec path; redirects are not followed.")
|
|
119
|
+
if result["truncated"]:
|
|
120
|
+
raise HTTPException(413, "OpenAPI document exceeds the 2 MiB limit.")
|
|
121
|
+
raw = json.loads(result["body"])
|
|
122
|
+
snapshot = normalize(raw)
|
|
123
|
+
except (ValueError, SpecError, RecursionError, TypeError, AttributeError) as exc:
|
|
124
|
+
raise HTTPException(422, f"Could not import OpenAPI: {exc}. Last good import is preserved.")
|
|
125
|
+
app.state.workspace.sync(snapshot)
|
|
126
|
+
if native_host:
|
|
127
|
+
app.state.workspace.sources = await asyncio.to_thread(app.state.git_source.collect, native_host)
|
|
128
|
+
app.state.snapshot, app.state.raw_spec = snapshot, raw
|
|
129
|
+
return snapshot
|
|
130
|
+
|
|
131
|
+
@app.get("/__relay/workspace")
|
|
132
|
+
def read_workspace():
|
|
133
|
+
return app.state.workspace.read()
|
|
134
|
+
|
|
135
|
+
@app.post("/__relay/endpoints/update")
|
|
136
|
+
def update_endpoint(payload: EndpointUpdate):
|
|
137
|
+
app.state.workspace.update(payload.endpoint_id, payload.fingerprint, payload.progress, payload.note)
|
|
138
|
+
return app.state.workspace.read()
|
|
139
|
+
|
|
140
|
+
@app.get("/__relay/sources/current")
|
|
141
|
+
def current():
|
|
142
|
+
return {"snapshot": app.state.snapshot, "raw_spec": app.state.raw_spec}
|
|
143
|
+
|
|
144
|
+
@app.post("/__relay/execute")
|
|
145
|
+
async def execute(payload: ExecuteRequest, request: Request):
|
|
146
|
+
validate_path(payload.path)
|
|
147
|
+
decoded_path = posixpath.normpath(unquote(payload.path))
|
|
148
|
+
if native_host and (decoded_path == "/relay" or decoded_path.startswith("/relay/")):
|
|
149
|
+
raise HTTPException(400, "Relay's own routes cannot be executed.")
|
|
150
|
+
url = target_base(request) + payload.path
|
|
151
|
+
root = request.scope.get("root_path", "").removesuffix("/relay") if native_host else ""
|
|
152
|
+
if native_host:
|
|
153
|
+
resolved_path = posixpath.normpath(unquote(httpx.URL(url).path))
|
|
154
|
+
if root and not resolved_path.startswith(root + "/"):
|
|
155
|
+
raise HTTPException(400, "Request path escapes the application's mount.")
|
|
156
|
+
route_path = resolved_path.removeprefix(root)
|
|
157
|
+
if route_path == "/relay" or route_path.startswith("/relay/"):
|
|
158
|
+
raise HTTPException(400, "Relay's own routes cannot be executed.")
|
|
159
|
+
headers = safe_headers(dict(payload.headers))
|
|
160
|
+
content = None
|
|
161
|
+
if payload.body_mode == "json":
|
|
162
|
+
try:
|
|
163
|
+
json.loads(payload.body)
|
|
164
|
+
except (ValueError, RecursionError):
|
|
165
|
+
raise HTTPException(400, "Invalid JSON body. Fix the JSON before executing.")
|
|
166
|
+
content = payload.body.encode()
|
|
167
|
+
headers = {k: v for k, v in headers.items() if k.lower() != "content-type"}
|
|
168
|
+
headers["Content-Type"] = "application/json"
|
|
169
|
+
elif payload.body_mode != "none":
|
|
170
|
+
content = payload.body.encode()
|
|
171
|
+
if not any(k.lower() == "content-type" for k in headers):
|
|
172
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded" if payload.body_mode == "form" else "text/plain"
|
|
173
|
+
if native_host:
|
|
174
|
+
async def bounded_host(scope, receive, send_message):
|
|
175
|
+
# HTTPX's ASGI transport buffers response chunks. Cap what it
|
|
176
|
+
# retains, while allowing the application to finish normally.
|
|
177
|
+
retained = 0
|
|
178
|
+
scope["state"] = dict(request.scope.get("state", {}))
|
|
179
|
+
async def bounded_send(message):
|
|
180
|
+
nonlocal retained
|
|
181
|
+
if message["type"] == "http.response.body":
|
|
182
|
+
chunk = message.get("body", b"")[:max(0, MAX_RESPONSE + 1 - retained)]
|
|
183
|
+
retained += len(chunk)
|
|
184
|
+
message = {**message, "body": chunk}
|
|
185
|
+
await send_message(message)
|
|
186
|
+
await native_host(scope, receive, bounded_send)
|
|
187
|
+
native_transport = httpx.ASGITransport(app=bounded_host, root_path=root, raise_app_exceptions=False,
|
|
188
|
+
client=(request.client.host, request.client.port))
|
|
189
|
+
# Never reuse cookies from a previous response as implicit auth.
|
|
190
|
+
async with httpx.AsyncClient(transport=native_transport, follow_redirects=False, trust_env=False) as client:
|
|
191
|
+
return await send(client, payload.method, url, headers=headers, query=payload.query, content=content)
|
|
192
|
+
return await send(app.state.client, payload.method, url, headers=headers, query=payload.query, content=content)
|
|
193
|
+
|
|
194
|
+
bundled = Path(__file__).resolve().parent / "static"
|
|
195
|
+
static_dir = static_dir or (bundled if bundled.is_dir() else Path(__file__).resolve().parents[2] / "web" / "dist")
|
|
196
|
+
if static_dir.is_dir():
|
|
197
|
+
# Preserve CLI API URLs while the browser uses namespaced URLs.
|
|
198
|
+
if not native_host:
|
|
199
|
+
aliases = APIRouter()
|
|
200
|
+
aliases.routes = list(app.router.routes)
|
|
201
|
+
app.include_router(aliases, prefix="/relay", include_in_schema=False)
|
|
202
|
+
@app.get("/" if native_host else "/relay", include_in_schema=False)
|
|
203
|
+
@app.get("/index.html" if native_host else "/relay/", include_in_schema=False)
|
|
204
|
+
def relay_ui(request: Request):
|
|
205
|
+
base = request.scope.get("root_path", "") if native_host else request.scope.get("root_path", "") + "/relay"
|
|
206
|
+
html = (static_dir / "index.html").read_text(encoding="utf-8")
|
|
207
|
+
html = html.replace("<head>", f'<head><base href="{escape(base + "/", quote=True)}">', 1)
|
|
208
|
+
return HTMLResponse(html)
|
|
209
|
+
if not native_host:
|
|
210
|
+
app.mount("/relay", StaticFiles(directory=static_dir, html=True), name="relay-web")
|
|
211
|
+
app.mount("/", StaticFiles(directory=static_dir, html=True), name="web")
|
|
212
|
+
else:
|
|
213
|
+
@app.get("/")
|
|
214
|
+
def missing_ui():
|
|
215
|
+
return JSONResponse({"detail": "Relay's bundled UI is missing. Reinstall the Relay package."}, 503)
|
|
216
|
+
return app
|
relay_agent/cli.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import hashlib
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import uvicorn
|
|
7
|
+
|
|
8
|
+
from .app import create_app
|
|
9
|
+
from .security import Settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
if len(sys.argv) > 1 and sys.argv[1] in {"open", "associate"}:
|
|
14
|
+
from .files import associate_windows, open_report
|
|
15
|
+
action = sys.argv[1]
|
|
16
|
+
file_parser = argparse.ArgumentParser(description="Open Relay reports or enable Windows double-click support.")
|
|
17
|
+
if action == "open":
|
|
18
|
+
file_parser.add_argument("file", type=Path)
|
|
19
|
+
file_args = file_parser.parse_args(sys.argv[2:])
|
|
20
|
+
try:
|
|
21
|
+
if action == "open":
|
|
22
|
+
open_report(file_args.file)
|
|
23
|
+
else:
|
|
24
|
+
associate_windows()
|
|
25
|
+
print("Relay .relay reports now open in your default browser. Existing Windows user choices remain unchanged.")
|
|
26
|
+
except (OSError, UnicodeError, ValueError, RuntimeError) as error:
|
|
27
|
+
file_parser.error(str(error))
|
|
28
|
+
return
|
|
29
|
+
parser = argparse.ArgumentParser(description="Relay local API explorer. Targets are approved explicitly at startup.")
|
|
30
|
+
parser.add_argument("--target", default="http://127.0.0.1:8000")
|
|
31
|
+
parser.add_argument("--spec-path", default="/openapi.json")
|
|
32
|
+
parser.add_argument("--port", type=int, default=4477)
|
|
33
|
+
parser.add_argument("--static-dir", type=Path)
|
|
34
|
+
parser.add_argument("--data-dir", type=Path, default=Path.home() / ".relay")
|
|
35
|
+
parser.add_argument("--project", type=Path, help="Git repository for shared .relay/status.json metadata")
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
try:
|
|
38
|
+
settings = Settings(args.target, args.spec_path, args.port)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
parser.error(str(exc))
|
|
41
|
+
workspace_id = hashlib.sha256((str(args.project.resolve()) if args.project else settings.base_url.rstrip("/")).encode()).hexdigest()[:16]
|
|
42
|
+
db_path = args.data_dir / workspace_id / "workspace.sqlite3"
|
|
43
|
+
try:
|
|
44
|
+
app = create_app(settings, static_dir=args.static_dir, db_path=db_path, project=args.project)
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
parser.error(str(exc))
|
|
47
|
+
uvicorn.run(app, host="127.0.0.1", port=args.port, access_log=False)
|
relay_agent/executor.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from fastapi import HTTPException
|
|
6
|
+
|
|
7
|
+
from .security import MAX_RESPONSE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def send(client: httpx.AsyncClient, method: str, url: str, *, headers=None, query=None,
|
|
11
|
+
content=None, limit=MAX_RESPONSE):
|
|
12
|
+
started = time.perf_counter()
|
|
13
|
+
try:
|
|
14
|
+
async with asyncio.timeout(20):
|
|
15
|
+
async with client.stream(method, url, headers=headers, params=query, content=content) as response:
|
|
16
|
+
data = bytearray()
|
|
17
|
+
truncated = False
|
|
18
|
+
async for chunk in response.aiter_bytes(chunk_size=65536):
|
|
19
|
+
remaining = limit - len(data)
|
|
20
|
+
data.extend(chunk[:remaining])
|
|
21
|
+
if len(chunk) > remaining:
|
|
22
|
+
truncated = True
|
|
23
|
+
break
|
|
24
|
+
return {"status": response.status_code, "reason": response.reason_phrase,
|
|
25
|
+
"headers": dict(response.headers), "body": data.decode(response.encoding or "utf-8", errors="replace"),
|
|
26
|
+
"content_type": response.headers.get("content-type", ""), "bytes": len(data),
|
|
27
|
+
"truncated": truncated, "duration_ms": round((time.perf_counter() - started) * 1000, 1),
|
|
28
|
+
"url": str(response.url), "method": method}
|
|
29
|
+
except (httpx.TimeoutException, TimeoutError):
|
|
30
|
+
raise HTTPException(504, "Request timed out after 20 seconds. Check the backend and try again.")
|
|
31
|
+
except httpx.RequestError:
|
|
32
|
+
raise HTTPException(502, "Could not connect to the configured API. Start your backend and check its address and port.")
|
relay_agent/files.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Open portable Relay reports and register their Windows file association."""
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
import sys
|
|
5
|
+
import webbrowser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
REPORT_MARKER = '<meta name="relay-format" content="relay.endpoint.report.v1">'
|
|
9
|
+
PROG_ID = "Relay.EndpointReport"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def prepare_report(path: Path, cache_dir: Path | None = None) -> Path:
|
|
13
|
+
path = path.expanduser().resolve(strict=True)
|
|
14
|
+
if path.suffix.lower() != ".relay":
|
|
15
|
+
raise ValueError("Choose a .relay endpoint report.")
|
|
16
|
+
if path.stat().st_size > 32 * 1024 * 1024:
|
|
17
|
+
raise ValueError("Relay report exceeds the 32 MiB limit.")
|
|
18
|
+
data = path.read_bytes()
|
|
19
|
+
content = data.decode("utf-8-sig")
|
|
20
|
+
if REPORT_MARKER not in content or not content.lstrip().lower().startswith("<!doctype html>"):
|
|
21
|
+
raise ValueError("This is not a supported Relay browser report.")
|
|
22
|
+
# A .html copy gives every browser the correct local-file content type.
|
|
23
|
+
# Keep the original report intact; no backend or HTTP server is required.
|
|
24
|
+
root = cache_dir or Path.home() / ".relay" / "reports"
|
|
25
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
destination = root / (hashlib.sha256(data).hexdigest() + ".html")
|
|
27
|
+
destination.write_bytes(data)
|
|
28
|
+
return destination
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def open_report(path: Path) -> Path:
|
|
32
|
+
destination = prepare_report(path)
|
|
33
|
+
if not webbrowser.open(destination.as_uri()):
|
|
34
|
+
raise RuntimeError(f"Could not open your browser. Open {destination} manually.")
|
|
35
|
+
return destination
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def associate_windows() -> str:
|
|
39
|
+
if sys.platform != "win32":
|
|
40
|
+
raise ValueError("Automatic association currently supports Windows. Use relay open <file.relay> on this platform.")
|
|
41
|
+
import ctypes
|
|
42
|
+
import subprocess
|
|
43
|
+
import winreg
|
|
44
|
+
|
|
45
|
+
extension = r"Software\Classes\.relay"
|
|
46
|
+
# Do not replace another application's association.
|
|
47
|
+
for hive, key_path in ((winreg.HKEY_CURRENT_USER, extension), (winreg.HKEY_CLASSES_ROOT, ".relay")):
|
|
48
|
+
try:
|
|
49
|
+
with winreg.OpenKey(hive, key_path) as key:
|
|
50
|
+
existing = winreg.QueryValue(key, None)
|
|
51
|
+
if existing and existing != PROG_ID:
|
|
52
|
+
raise ValueError(".relay already belongs to another application. Choose Relay explicitly in Windows Open with settings.")
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
pass
|
|
55
|
+
python = Path(sys.executable)
|
|
56
|
+
pythonw = python.with_name("pythonw.exe")
|
|
57
|
+
executable = pythonw if pythonw.is_file() else python
|
|
58
|
+
command = subprocess.list2cmdline([str(executable), "-m", "relay_agent.files"]) + ' "%1"'
|
|
59
|
+
values = {
|
|
60
|
+
extension: PROG_ID,
|
|
61
|
+
rf"Software\Classes\{PROG_ID}": "Relay endpoint report",
|
|
62
|
+
rf"Software\Classes\{PROG_ID}\shell\open\command": command,
|
|
63
|
+
}
|
|
64
|
+
for key_path, value in values.items():
|
|
65
|
+
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path) as key:
|
|
66
|
+
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, value)
|
|
67
|
+
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, extension) as key:
|
|
68
|
+
winreg.SetValueEx(key, "Content Type", 0, winreg.REG_SZ, "text/html")
|
|
69
|
+
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0, None, None)
|
|
70
|
+
return command
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main():
|
|
74
|
+
parser = argparse.ArgumentParser(description="Open a Relay endpoint report in your default browser.")
|
|
75
|
+
parser.add_argument("file", type=Path)
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
try:
|
|
78
|
+
open_report(args.file)
|
|
79
|
+
except (OSError, UnicodeError, ValueError, RuntimeError) as error:
|
|
80
|
+
if sys.platform == "win32" and sys.executable.lower().endswith("pythonw.exe"):
|
|
81
|
+
import ctypes
|
|
82
|
+
ctypes.windll.user32.MessageBoxW(None, str(error), "Relay report", 0x10)
|
|
83
|
+
else:
|
|
84
|
+
parser.error(str(error))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Read-only source attribution for native FastAPI endpoints.
|
|
2
|
+
|
|
3
|
+
Creation is inferred from the oldest available history of the handler's
|
|
4
|
+
definition line. It is not ownership or verified identity.
|
|
5
|
+
"""
|
|
6
|
+
import ast
|
|
7
|
+
import inspect
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi.routing import APIRoute
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def api_routes(routes, prefix=""):
|
|
15
|
+
"""Support flattened routes and FastAPI's newer included-router branches."""
|
|
16
|
+
for route in routes:
|
|
17
|
+
if isinstance(route, APIRoute):
|
|
18
|
+
if route.include_in_schema:
|
|
19
|
+
yield route, prefix + route.path
|
|
20
|
+
else:
|
|
21
|
+
router = getattr(route, "original_router", None)
|
|
22
|
+
context = getattr(route, "include_context", None)
|
|
23
|
+
if router is not None and context is not None and context.include_in_schema:
|
|
24
|
+
yield from api_routes(router.routes, prefix + context.prefix)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitSource:
|
|
28
|
+
def __init__(self, root: Path | None):
|
|
29
|
+
self.root = root
|
|
30
|
+
self.cache = {}
|
|
31
|
+
self.files = {}
|
|
32
|
+
|
|
33
|
+
def git(self, *args):
|
|
34
|
+
result = subprocess.run(["git", "-C", str(self.root), "-c", "core.quotepath=false", *args],
|
|
35
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5)
|
|
36
|
+
if result.returncode:
|
|
37
|
+
raise ValueError("Git history unavailable")
|
|
38
|
+
return result.stdout
|
|
39
|
+
|
|
40
|
+
def collect(self, app):
|
|
41
|
+
try:
|
|
42
|
+
head = self.git("rev-parse", "HEAD").strip() if self.root else None
|
|
43
|
+
except (ValueError, OSError, subprocess.TimeoutExpired):
|
|
44
|
+
head = None
|
|
45
|
+
sources = {}
|
|
46
|
+
for route, route_path in api_routes(app.routes):
|
|
47
|
+
try:
|
|
48
|
+
handler = inspect.unwrap(route.endpoint)
|
|
49
|
+
file = Path(inspect.getsourcefile(handler)).resolve()
|
|
50
|
+
lines, start = inspect.getsourcelines(handler)
|
|
51
|
+
# inspect includes route decorators; the definition itself is
|
|
52
|
+
# the stable anchor for tracing this handler back through Git.
|
|
53
|
+
import textwrap
|
|
54
|
+
tree = ast.parse(textwrap.dedent("".join(lines)))
|
|
55
|
+
node = next(n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)))
|
|
56
|
+
definition = start + node.lineno - 1
|
|
57
|
+
end = start + len(lines) - 1
|
|
58
|
+
relative = file.relative_to(self.root).as_posix() if self.root else file.name
|
|
59
|
+
key = (head, relative, start, definition, end, file.stat().st_mtime_ns)
|
|
60
|
+
if key not in self.cache:
|
|
61
|
+
self.cache[key] = self.history(relative, start, definition, end, head, handler.__name__)
|
|
62
|
+
source = self.cache[key]
|
|
63
|
+
except (ValueError, TypeError, OSError, StopIteration, SyntaxError):
|
|
64
|
+
source = {"state": "unavailable"}
|
|
65
|
+
for method in route.methods:
|
|
66
|
+
sources[f"{method} {route_path}"] = source
|
|
67
|
+
# Bound the cache across auto-sync and edits.
|
|
68
|
+
if len(self.cache) > 1024:
|
|
69
|
+
self.cache.clear()
|
|
70
|
+
self.files.clear()
|
|
71
|
+
return sources
|
|
72
|
+
|
|
73
|
+
def history(self, file, start, definition, end, head, name):
|
|
74
|
+
source = {"file": file, "line": definition, "state": "unavailable"}
|
|
75
|
+
if not head:
|
|
76
|
+
if self.root:
|
|
77
|
+
source["state"] = "uncommitted"
|
|
78
|
+
return source
|
|
79
|
+
try:
|
|
80
|
+
file_key = (head, file)
|
|
81
|
+
if file_key not in self.files:
|
|
82
|
+
try:
|
|
83
|
+
self.files[file_key] = ast.parse(self.git("show", f"{head}:{file}"))
|
|
84
|
+
except ValueError:
|
|
85
|
+
source["state"] = "uncommitted"
|
|
86
|
+
return source
|
|
87
|
+
definitions = [n for n in ast.walk(self.files[file_key])
|
|
88
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name]
|
|
89
|
+
if definitions:
|
|
90
|
+
node = min(definitions, key=lambda n: abs(n.lineno - definition))
|
|
91
|
+
committed_start = min([node.lineno, *(d.lineno for d in node.decorator_list)])
|
|
92
|
+
history = self.git("log", "--no-patch", "--format=%H%x00%an%x00%ae%x00%aI",
|
|
93
|
+
"-L", f"{committed_start},{node.end_lineno}:{file}", head)
|
|
94
|
+
entries = [line.split("\0") for line in history.splitlines() if line.count("\0") == 3]
|
|
95
|
+
if entries:
|
|
96
|
+
commit, author, email, date = entries[0]
|
|
97
|
+
source.update(last_changed_by={"name": author, "email": email}, commit=commit, changed_at=date)
|
|
98
|
+
output = self.git("blame", "--line-porcelain", "-L", f"{start},{end}", "--", file)
|
|
99
|
+
records = []
|
|
100
|
+
record = {}
|
|
101
|
+
for line in output.splitlines():
|
|
102
|
+
if not record:
|
|
103
|
+
sha, original, final, *_ = line.split()
|
|
104
|
+
record = {"commit": sha, "original": int(original), "final": int(final)}
|
|
105
|
+
elif line.startswith("\t"):
|
|
106
|
+
records.append(record)
|
|
107
|
+
record = {}
|
|
108
|
+
else:
|
|
109
|
+
name, _, value = line.partition(" ")
|
|
110
|
+
record[name] = value
|
|
111
|
+
committed = [r for r in records if set(r["commit"]) != {"0"}]
|
|
112
|
+
source["state"] = "uncommitted" if len(committed) != len(records) else "tracked"
|
|
113
|
+
anchor = next((r for r in committed if r["final"] == definition), None)
|
|
114
|
+
if anchor:
|
|
115
|
+
old_file = anchor["filename"]
|
|
116
|
+
if old_file.startswith('"'):
|
|
117
|
+
old_file = ast.literal_eval(old_file)
|
|
118
|
+
history = self.git("log", "--no-patch", "--format=%H%x00%an%x00%ae%x00%aI",
|
|
119
|
+
"-L", f'{anchor["original"]},{anchor["original"]}:{old_file}', anchor["commit"])
|
|
120
|
+
entries = [line.split("\0") for line in history.splitlines() if line.count("\0") == 3]
|
|
121
|
+
if entries:
|
|
122
|
+
commit, name, email, date = entries[-1]
|
|
123
|
+
source.update(created_by={"name": name, "email": email}, created_commit=commit, created_at=date)
|
|
124
|
+
except (ValueError, KeyError, OSError, subprocess.TimeoutExpired, SyntaxError):
|
|
125
|
+
# Missing/untracked files, shallow history, and failed Git commands
|
|
126
|
+
# never prevent API discovery or testing.
|
|
127
|
+
pass
|
|
128
|
+
return source
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Git-trackable status metadata. No Git commits or network operations are automatic."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
|
|
12
|
+
PROGRESS = {"not_started", "in_progress", "done", "needs_fixing"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitStatus:
|
|
16
|
+
def __init__(self, project: Path):
|
|
17
|
+
self.root = Path(self._git(project, "rev-parse", "--show-toplevel")).resolve()
|
|
18
|
+
self.path = self.root / ".relay" / "status.json"
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def _git(project, *args):
|
|
22
|
+
result = subprocess.run(["git", "-C", str(project), *args], capture_output=True, text=True, timeout=5)
|
|
23
|
+
if result.returncode:
|
|
24
|
+
raise ValueError("Relay --project requires an existing Git repository.")
|
|
25
|
+
return result.stdout.strip()
|
|
26
|
+
|
|
27
|
+
def read(self):
|
|
28
|
+
if not self.path.exists():
|
|
29
|
+
return {}
|
|
30
|
+
try:
|
|
31
|
+
if self.path.stat().st_size > 2 * 1024 * 1024:
|
|
32
|
+
raise ValueError("Status file is too large")
|
|
33
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
34
|
+
if data.get("version") != 1 or not isinstance(data.get("endpoints"), dict):
|
|
35
|
+
raise ValueError("Unknown status format")
|
|
36
|
+
for key, row in data["endpoints"].items():
|
|
37
|
+
if not isinstance(key, str) or not isinstance(row, dict) or row.get("progress") not in PROGRESS:
|
|
38
|
+
raise ValueError("Invalid endpoint status")
|
|
39
|
+
if not isinstance(row.get("fingerprint"), str) or len(row["fingerprint"]) != 64:
|
|
40
|
+
raise ValueError("Invalid contract fingerprint")
|
|
41
|
+
author = row.get("updated_by")
|
|
42
|
+
if not isinstance(author, dict) or not all(isinstance(author.get(k), str) for k in ("name", "email")):
|
|
43
|
+
raise ValueError("Invalid author")
|
|
44
|
+
if not isinstance(row.get("updated_at"), str):
|
|
45
|
+
raise ValueError("Invalid timestamp")
|
|
46
|
+
return data["endpoints"]
|
|
47
|
+
except (ValueError, OSError, AttributeError, TypeError) as exc:
|
|
48
|
+
raise HTTPException(409, "Cannot read .relay/status.json. Resolve Git conflicts or fix the file before changing status.") from exc
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def _lock(self):
|
|
52
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
lock = self.path.with_suffix(".lock")
|
|
54
|
+
try:
|
|
55
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
56
|
+
except FileExistsError as exc:
|
|
57
|
+
raise HTTPException(409, "Another Relay process is updating statuses. Retry shortly; remove a stale .relay/status.lock after stopping Relay.") from exc
|
|
58
|
+
try:
|
|
59
|
+
os.close(fd)
|
|
60
|
+
yield
|
|
61
|
+
finally:
|
|
62
|
+
lock.unlink(missing_ok=True)
|
|
63
|
+
|
|
64
|
+
def update(self, endpoint_id, fingerprint, progress):
|
|
65
|
+
try:
|
|
66
|
+
name = self._git(self.root, "config", "user.name")
|
|
67
|
+
email = self._git(self.root, "config", "user.email")
|
|
68
|
+
if not name or not email:
|
|
69
|
+
raise ValueError("Missing identity")
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise HTTPException(409, "Configure Git user.name and user.email before changing a shared status.") from exc
|
|
72
|
+
with self._lock():
|
|
73
|
+
rows = self.read()
|
|
74
|
+
rows[endpoint_id] = {"fingerprint": fingerprint, "progress": progress,
|
|
75
|
+
"updated_by": {"name": name, "email": email}, "updated_at": datetime.now(timezone.utc).isoformat()}
|
|
76
|
+
fd, temporary = tempfile.mkstemp(prefix="status-", suffix=".tmp", dir=self.path.parent)
|
|
77
|
+
try:
|
|
78
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
|
|
79
|
+
json.dump({"version": 1, "endpoints": dict(sorted(rows.items()))}, stream, indent=2, ensure_ascii=False)
|
|
80
|
+
stream.write("\n")
|
|
81
|
+
os.replace(temporary, self.path)
|
|
82
|
+
finally:
|
|
83
|
+
Path(temporary).unlink(missing_ok=True)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Opt-in, same-process FastAPI integration."""
|
|
2
|
+
import hashlib
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from .app import create_app
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def install_relay(app: FastAPI, *, enabled: bool = True, project: Path | None = None,
|
|
11
|
+
data_dir: Path | None = None, db_path: str | Path | None = None,
|
|
12
|
+
static_dir: Path | None = None) -> FastAPI | None:
|
|
13
|
+
"""Mount the bundled tester at /relay. Enable only in development.
|
|
14
|
+
|
|
15
|
+
No target URL, subprocess, or extra port is required. The parent lifespan
|
|
16
|
+
runs normally; Relay owns only its client/workspace resources.
|
|
17
|
+
"""
|
|
18
|
+
if not enabled:
|
|
19
|
+
return None
|
|
20
|
+
if getattr(app.state, "relay", None) is not None:
|
|
21
|
+
raise ValueError("Relay is already installed on this application.")
|
|
22
|
+
for route in app.routes:
|
|
23
|
+
path = getattr(route, "path", "")
|
|
24
|
+
if path == "/relay" or path.startswith("/relay/"):
|
|
25
|
+
raise ValueError("The /relay namespace is already in use.")
|
|
26
|
+
if project is None:
|
|
27
|
+
project = next((p for p in (Path.cwd(), *Path.cwd().parents)
|
|
28
|
+
if (p / ".git").exists()), None)
|
|
29
|
+
if db_path is None:
|
|
30
|
+
identity = f"{Path(project or Path.cwd()).resolve()}:{app.title}"
|
|
31
|
+
workspace_id = hashlib.sha256(identity.encode()).hexdigest()[:16]
|
|
32
|
+
db_path = Path(data_dir or Path.home() / ".relay") / workspace_id / "workspace.sqlite3"
|
|
33
|
+
relay = create_app(native_host=app, static_dir=static_dir, db_path=db_path, project=project)
|
|
34
|
+
previous = app.router.lifespan_context
|
|
35
|
+
|
|
36
|
+
@asynccontextmanager
|
|
37
|
+
async def lifespan(application):
|
|
38
|
+
async with previous(application) as state:
|
|
39
|
+
# Mounted apps do not receive lifespan automatically.
|
|
40
|
+
async with relay.router.lifespan_context(relay):
|
|
41
|
+
yield state
|
|
42
|
+
|
|
43
|
+
app.router.lifespan_context = lifespan
|
|
44
|
+
app.mount("/relay", relay, name="relay")
|
|
45
|
+
# Reserve this namespace even when the host has a catch-all route.
|
|
46
|
+
app.router.routes.insert(0, app.router.routes.pop())
|
|
47
|
+
app.state.relay = relay
|
|
48
|
+
return relay
|