agnview 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.
@@ -0,0 +1,3 @@
1
+ """AgentRelay: Cross-Agent Orchestration & Status Synchronization Hub."""
2
+
3
+ __version__ = "0.1.0"
agent_relay/api/app.py ADDED
@@ -0,0 +1,161 @@
1
+ import asyncio
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.responses import FileResponse, JSONResponse
9
+
10
+ from .routes import router as api_router
11
+ from ..core.engine import RelayEngine
12
+ from ..core.db import Database
13
+ from ..core.config import load_config
14
+ from ..core.iroh_transport import IrohTransport
15
+
16
+
17
+ # Set AGNVIEW_IROH=0 to keep the hub LAN only. The default is on, because the
18
+ # whole point of the iroh transport is that the user never sets anything up.
19
+ IROH_ENV = "AGNVIEW_IROH"
20
+
21
+
22
+ def _iroh_enabled_from_env() -> bool:
23
+ value = os.environ.get(IROH_ENV)
24
+ if value is None:
25
+ return True
26
+ return value.strip().lower() not in ("0", "false", "no", "off")
27
+
28
+
29
+ def create_app(db_path: Optional[str] = None, auth_token: Optional[str] = None, port: int = 8765) -> FastAPI:
30
+ token = auth_token or os.environ.get("AGENT_RELAY_TOKEN")
31
+
32
+ app = FastAPI(
33
+ title="AgnView API",
34
+ description="Cross-Agent Coordination Hub & Live Console for Claude Code, Codex, AntiGravity, and Web LLMs",
35
+ version="0.1.0"
36
+ )
37
+
38
+ # Enable CORS for external tools, mobile apps, and browser extensions
39
+ app.add_middleware(
40
+ CORSMiddleware,
41
+ allow_origins=["*"],
42
+ allow_credentials=True,
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+ # Token Authentication Middleware for distributed multi-computer and mobile setups
48
+ if token:
49
+ @app.middleware("http")
50
+ async def verify_token_middleware(request: Request, call_next):
51
+ # Only authenticate API endpoints; allow static dashboard index and docs
52
+ path = request.url.path
53
+ if path.startswith("/api") and not path.startswith("/api/mobile/pairing"):
54
+ client_ip = request.client.host if request.client else "127.0.0.1"
55
+ # Allow unauthenticated access only for local browser same-origin sessions (not programmatic API clients/tests)
56
+ is_local_browser = (
57
+ client_ip in ("127.0.0.1", "::1", "localhost")
58
+ and (
59
+ request.headers.get("Sec-Fetch-Site") == "same-origin"
60
+ or request.headers.get("Referer", "").startswith(("http://localhost:", "http://127.0.0.1:", "http://[::1]:"))
61
+ )
62
+ )
63
+ if not is_local_browser:
64
+ from ..core.pairing import check_auth_rate_limit, record_failed_auth, reset_auth_rate_limit
65
+
66
+ # Check rate limiting for failed auth attempts
67
+ if not check_auth_rate_limit(client_ip):
68
+ return JSONResponse(
69
+ status_code=429,
70
+ content={"detail": "Too many failed authentication attempts. Please try again later."}
71
+ )
72
+
73
+ auth_header = (
74
+ request.headers.get("X-AgnView-Token") or
75
+ request.headers.get("X-Agent-Relay-Token") or
76
+ request.headers.get("Authorization")
77
+ )
78
+ token_param = request.query_params.get("token")
79
+
80
+ provided = None
81
+ if auth_header:
82
+ if auth_header.startswith("Bearer "):
83
+ provided = auth_header[7:].strip()
84
+ else:
85
+ provided = auth_header.strip()
86
+ elif token_param:
87
+ provided = token_param.strip()
88
+
89
+ expected = request.app.state.auth_token or token
90
+ if not provided or provided != expected:
91
+ record_failed_auth(client_ip)
92
+ return JSONResponse(
93
+ status_code=401,
94
+ content={"detail": "Unauthorized: Invalid or missing AgnView authentication token."}
95
+ )
96
+
97
+ reset_auth_rate_limit(client_ip)
98
+
99
+ return await call_next(request)
100
+
101
+ db = Database(db_path)
102
+ engine = RelayEngine(db, port=port)
103
+ app.state.db = db
104
+ app.state.engine = engine
105
+ app.state.auth_token = token
106
+
107
+ config = load_config()
108
+ app.state.config = config
109
+
110
+ # A configuration the hub cannot act on stops iroh and nothing else. The
111
+ # reason is already in the log and travels to the API in the status.
112
+ if not _iroh_enabled_from_env():
113
+ iroh_enabled, disabled_reason = False, f"{IROH_ENV} is set to off"
114
+ elif not config.iroh_enabled:
115
+ iroh_enabled, disabled_reason = False, f"iroh_enabled is false in {config.path}"
116
+ elif not config.is_valid:
117
+ iroh_enabled, disabled_reason = False, "; ".join(config.errors)
118
+ else:
119
+ iroh_enabled, disabled_reason = True, ""
120
+
121
+ iroh_transport = IrohTransport(
122
+ db=db,
123
+ token_provider=lambda: app.state.auth_token,
124
+ relay_url=config.relay_url,
125
+ enabled=iroh_enabled,
126
+ disabled_reason=disabled_reason,
127
+ )
128
+ app.state.iroh = iroh_transport
129
+
130
+ @app.on_event("startup")
131
+ async def _bind_event_loop():
132
+ # Job and task routes are sync, so they execute on worker threads.
133
+ # Remember the serving loop so their broadcasts still reach SSE clients.
134
+ engine.bind_loop(asyncio.get_running_loop())
135
+
136
+ @app.on_event("startup")
137
+ async def _start_iroh_transport():
138
+ # start() schedules the bind and returns. Startup never waits on the
139
+ # network, so a hub with no route out still comes up and serves the
140
+ # dashboard.
141
+ iroh_transport.start()
142
+
143
+ @app.on_event("shutdown")
144
+ async def _stop_iroh_transport():
145
+ await iroh_transport.stop()
146
+
147
+ app.include_router(api_router)
148
+
149
+ # Web Dashboard Static UI & Assets
150
+ web_dir = Path(__file__).parent.parent / "web"
151
+ static_dir = web_dir / "static"
152
+ if static_dir.exists():
153
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
154
+
155
+ index_file = web_dir / "templates" / "index.html"
156
+ if index_file.exists():
157
+ @app.get("/", include_in_schema=False)
158
+ async def serve_index():
159
+ return FileResponse(str(index_file))
160
+
161
+ return app