gemini-computer-use 0.1.2 → 0.2.4

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.
package/gateway.py ADDED
@@ -0,0 +1,1491 @@
1
+ import asyncio
2
+ from contextlib import asynccontextmanager
3
+ import datetime
4
+ import hmac
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import secrets
10
+ import shutil
11
+ import socket
12
+ import time
13
+ import uuid
14
+ from urllib.parse import urlparse
15
+ from starlette.applications import Starlette
16
+ from starlette.middleware import Middleware
17
+ from starlette.middleware.cors import CORSMiddleware
18
+ from starlette.requests import Request
19
+ from starlette.responses import JSONResponse, Response, StreamingResponse
20
+ from starlette.routing import Route, WebSocketRoute
21
+ from starlette.websockets import WebSocket, WebSocketDisconnect
22
+ import uvicorn
23
+
24
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
25
+ logger = logging.getLogger("gateway")
26
+
27
+ REGISTRY_PATH = "/etc/antigravity-mesh/registry.json"
28
+ active_tunnels = {} # user -> {"ws": WebSocket, "pending": {req_id: Future}}
29
+ active_sse_subscribers = {} # user -> set of asyncio.Queue
30
+ active_sse_sessions = {} # session_id -> asyncio.Queue
31
+ active_sessions = {} # session_id -> user, issued once per initialize
32
+ latest_session_by_user = {} # user -> most recently issued session id
33
+
34
+ # Negotiated protocol version per user. The value must be identical in the
35
+ # initialize result AND in the mcp-protocol-version header of every response: a
36
+ # client that asked for 2025-11-25 and reads 2024-11-05 back in the header can
37
+ # reject the answer as belonging to a different protocol revision.
38
+ active_protocol_versions = {}
39
+ DEFAULT_PROTOCOL_VERSION = "2024-11-05"
40
+
41
+ # Legacy SSE transport experiment. The classic MCP SSE transport answers a POST
42
+ # with 202 Accepted and an EMPTY body, and delivers the result only on the stream.
43
+ # Set MESH_LEGACY_SSE=1 to switch a client that holds an open stream onto that
44
+ # contract; leave it unset for the current "answer in the body too" behaviour.
45
+ LEGACY_SSE = os.environ.get("MESH_LEGACY_SSE", "").strip() in ("1", "true", "yes")
46
+
47
+ # Answering a POST in the HTTP body AND on the caller's SSE stream hands a strict
48
+ # streamable-HTTP client the SAME response id twice, and it rejects the session.
49
+ # The body alone is what /sse and /mcp clients read - the `initialize` response,
50
+ # which is answered before any stream exists, is proof that they do. Only the
51
+ # legacy /messages transport needs the stream, and LEGACY_SSE (202, no body)
52
+ # deliberately needs it too.
53
+ # Set MESH_BROADCAST_HTTP_RESPONSES=1 to duplicate on purpose while debugging.
54
+ BROADCAST_HTTP_RESPONSES = os.environ.get(
55
+ "MESH_BROADCAST_HTTP_RESPONSES", "0"
56
+ ).strip().lower() in ("1", "true", "yes", "on")
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # ONE SHARED DOMAIN FOR EVERY NODE (canonical URL contract)
60
+ # ---------------------------------------------------------------------------
61
+ # Every node is reached through the same public domain and is selected by the
62
+ # ?user= query parameter. Per-device subdomains are deliberately NOT issued:
63
+ # * each extra hostname needs its own DNS record and its own SAN in the TLS
64
+ # certificate, so a device whose name is missing from the certificate fails
65
+ # the handshake and the client reports an opaque "cannot connect to host";
66
+ # * the node name stops being a security-relevant routing key, which keeps the
67
+ # installer's "name already taken -> node-2" behaviour harmless.
68
+ # Canonical endpoints (see README "Connecting to Google Gemini"):
69
+ # SSE https://<shared-domain>/sse?user=<node>&token=<token>
70
+ # HTTP https://<shared-domain>/mcp?user=<node>&token=<token>
71
+ # Tunnel wss://<shared-domain>/ws/tunnel?user=<node>&token=<token>
72
+ # Override the shared domain with MESH_PUBLIC_URL (or AGY_PUBLIC_BASE_URL).
73
+ PUBLIC_BASE_URL = (
74
+ os.environ.get("MESH_PUBLIC_URL")
75
+ or os.environ.get("AGY_PUBLIC_BASE_URL")
76
+ or "https://smart-server.online"
77
+ ).strip().rstrip("/")
78
+ PUBLIC_HOST = urlparse(PUBLIC_BASE_URL).netloc or PUBLIC_BASE_URL
79
+
80
+ # Legacy per-device subdomain resolution. Kept only so URLs handed out before the
81
+ # shared-domain contract keep working; never advertised, and it logs a warning.
82
+ LEGACY_SUBDOMAIN_ACCESS = os.environ.get("MESH_LEGACY_SUBDOMAIN", "1").strip().lower() in (
83
+ "1", "true", "yes", "on"
84
+ )
85
+
86
+
87
+ def public_url(path: str, user: str, token: str) -> str:
88
+ """Canonical public URL for one node on the shared domain."""
89
+ return f"{PUBLIC_BASE_URL}{path}?user={user}&token={token}"
90
+
91
+
92
+ def issue_session_id(user: str) -> str:
93
+ """Issue a unique ``Mcp-Session-Id`` for one initialize.
94
+
95
+ The spec requires a session id to be unique per session, and Google's
96
+ frontends open several parallel connections per turn (a POST for the call,
97
+ a GET for the stream, a DELETE to tear the session down, often from
98
+ different egress IPs). Returning the user name as the session id -- as this
99
+ gateway used to -- gave every one of those connections the same identity, so
100
+ a DELETE sent by one of them looked like it had terminated the session that
101
+ another connection's in-flight tool call belonged to, and the client dropped
102
+ the answer it had already received. Clients that never echo the header keep
103
+ working: they still fall back to the user name.
104
+ """
105
+ session_id = uuid.uuid4().hex
106
+ active_sessions[session_id] = user
107
+ latest_session_by_user[user] = session_id
108
+ return session_id
109
+
110
+
111
+ def announce_endpoint_for_path(path: str) -> bool:
112
+ """Whether a GET stream on this path may emit the classic ``event: endpoint``.
113
+
114
+ The classic SSE transport announces the POST target (/messages). Streamable
115
+ HTTP (GET /mcp) must carry JSON-RPC frames only, so the frame is suppressed
116
+ there - a strict client otherwise drops the stream it reads results from.
117
+ """
118
+ return path != "/mcp"
119
+
120
+
121
+ def should_broadcast_response(path: str) -> bool:
122
+ """Whether a POST response is also queued on the caller's SSE stream.
123
+
124
+ Duplicating the answer (body + stream) hands a strict streamable-HTTP client
125
+ the same response id twice; it rejects the session and the model never sees
126
+ the result. Only the legacy transports need the stream.
127
+ """
128
+ return path == "/messages" or LEGACY_SSE or BROADCAST_HTTP_RESPONSES
129
+
130
+
131
+ def resolve_session_id(request: Request, user: str) -> str:
132
+ """The session id this connection must see.
133
+
134
+ Prefer the id the client sends back. Otherwise use the session this user most
135
+ recently opened: a client that holds a session but omits the header must not
136
+ be told a different id, or it believes it holds two sessions and aborts one
137
+ (that mismatch was the cause of the original initialize/DELETE churn). The
138
+ bare user name stays as the last resort for header-less legacy clients.
139
+ """
140
+ return (
141
+ request.headers.get("mcp-session-id")
142
+ or latest_session_by_user.get(user)
143
+ or user
144
+ )
145
+
146
+
147
+ def protocol_version_for(user: str) -> str:
148
+ return active_protocol_versions.get(user, DEFAULT_PROTOCOL_VERSION)
149
+
150
+ def broadcast_sse(user: str, message: dict) -> int:
151
+ """Queue a JSON-RPC frame on every open stream of this user.
152
+
153
+ Returns how many streams received it. The count is logged because it is the
154
+ only way to tell a client that reads results from the stream (Gemini Spark)
155
+ whether the answer actually had a live stream to land on: a response
156
+ broadcast to 0 streams reaches nobody on that channel.
157
+ """
158
+ data = json.dumps(message, ensure_ascii=False)
159
+ queues = list(active_sse_subscribers.get(user, ()))
160
+ for q in queues:
161
+ try:
162
+ q.put_nowait(data)
163
+ except Exception:
164
+ pass
165
+ label = message.get("method") or f"id={message.get('id')}"
166
+ logger.info(f"broadcast [{label}] for '{user}' to {len(queues)} open stream(s)")
167
+ return len(queues)
168
+
169
+ RESERVED_NAMES = {
170
+ "admin", "administrator", "root", "api", "api-ag", "api-agy",
171
+ "drive", "derp", "mcp-tg", "chamber", "proxy-api", "tg-bot-api",
172
+ "anonymous", "gateway", "mesh", "mail", "vpn", "test", "null",
173
+ "undefined", "localhost", "local", "internal", "public", "ws",
174
+ "sse", "mcp", "messages", "health", "health-mesh", "core", "skills",
175
+ "install", "static", "assets"
176
+ }
177
+
178
+ def load_registry() -> dict:
179
+ if os.path.exists(REGISTRY_PATH):
180
+ try:
181
+ with open(REGISTRY_PATH, "r") as f:
182
+ return json.load(f)
183
+ except Exception as e:
184
+ logger.error(f"Failed to read registry: {e}")
185
+ return {}
186
+
187
+ def save_registry(data: dict):
188
+ os.makedirs(os.path.dirname(REGISTRY_PATH), exist_ok=True)
189
+ with open(REGISTRY_PATH, "w") as f:
190
+ json.dump(data, f, indent=2)
191
+
192
+ def tokens_equal(a: str, b: str) -> bool:
193
+ """Constant-time token comparison that never raises on non-ASCII input.
194
+
195
+ ``hmac.compare_digest`` rejects str arguments containing non-ASCII
196
+ characters (TypeError -> HTTP 500). Comparing UTF-8 bytes with
197
+ ``surrogateescape`` keeps the comparison constant-time and total.
198
+ """
199
+ try:
200
+ return hmac.compare_digest(
201
+ str(a).encode("utf-8", "surrogateescape"),
202
+ str(b).encode("utf-8", "surrogateescape"),
203
+ )
204
+ except Exception:
205
+ return False
206
+
207
+
208
+ def get_target_user(request: Request) -> str:
209
+ """Resolve which node a request addresses.
210
+
211
+ Canonical form: ``<shared-domain>/...?user=<node>&token=<token>``. The shared
212
+ domain only selects the gateway, so ``?user=`` is authoritative. A per-device
213
+ subdomain is accepted as a legacy fallback (MESH_LEGACY_SUBDOMAIN=0 disables
214
+ it) and logs a warning telling the caller to switch to the canonical URL.
215
+ """
216
+ user = (request.query_params.get("user") or "").strip()
217
+ if user:
218
+ return user.lower()
219
+
220
+ if LEGACY_SUBDOMAIN_ACCESS:
221
+ host_part = request.headers.get("host", "").split(":")[0].lower()
222
+ base = PUBLIC_HOST.lower()
223
+ if host_part.endswith(f".{base}") and host_part != base:
224
+ sub = host_part[:-len(base)-1]
225
+ parts = sub.split(".")
226
+ legacy_user = parts[-1].lower()
227
+ logger.warning(
228
+ "Legacy per-device subdomain '%s' used instead of the shared domain; "
229
+ "switch to %s/sse?user=%s&token=...", host_part, PUBLIC_BASE_URL, legacy_user,
230
+ )
231
+ return legacy_user
232
+
233
+ return "anonymous"
234
+
235
+ def get_request_token(request: Request) -> str:
236
+ token = request.query_params.get("token") or request.headers.get("x-mesh-token")
237
+ if not token:
238
+ auth = request.headers.get("authorization", "")
239
+ if auth.startswith("Bearer "):
240
+ token = auth[7:].strip()
241
+ return token or ""
242
+
243
+ def verify_token(request: Request) -> tuple[bool, str, str]:
244
+ user = get_target_user(request)
245
+ if user == "anonymous":
246
+ return False, (
247
+ "Node identity is missing. Use the shared-domain URL form "
248
+ f"{PUBLIC_BASE_URL}/sse?user=<node-name>&token=<your_token>."
249
+ ), ""
250
+ registry = load_registry()
251
+ if user not in registry:
252
+ return False, f"User '{user}' is not registered.", user
253
+ expected = registry[user].get("token", "")
254
+ token = get_request_token(request)
255
+ if not token or not tokens_equal(token, expected):
256
+ return False, "Invalid or missing token. Specify ?token=<your_token> in the URL.", user
257
+ return True, "ok", user
258
+
259
+ def get_skill(user: str, host: str) -> str:
260
+ return f"""# 🌐 Antigravity Mesh — Node Orchestrator ({user})
261
+
262
+ Target Node: {user} (shared gateway {PUBLIC_HOST})
263
+ Endpoint: {PUBLIC_BASE_URL}/sse?user={user}&token=<your_token>
264
+ Mode: Secure Cloud Gateway + Reverse Tunnel (one shared domain for every node)
265
+
266
+ MANDATORY GROUNDING & EXECUTION DIRECTIVES:
267
+ 1. You are connected directly to the user's execution node '{user}' via Antigravity Mesh MCP tools.
268
+ 2. STRICT ZERO-HALLUCINATION POLICY:
269
+ - You DO NOT know local directory structure, file names, or environment state in advance.
270
+ - NEVER invent file paths, guess folder contents, or claim a directory is empty without checking.
271
+ - You MUST call `list_dir` or `bash_exec` (e.g. `pwd`, `ls -la`) to inspect the real filesystem first.
272
+ 3. DIRECT EXECUTION:
273
+ - Always execute operations directly via MCP tools (`list_dir`, `read_file`, `bash_exec`, `system_vitals`).
274
+ - Never ask the user to run shell commands manually.
275
+ 4. Always verify return codes and stdout/stderr before reporting results.
276
+ """
277
+
278
+ async def call_remote_tool(user: str, name: str, args: dict) -> dict:
279
+ tunnel = active_tunnels.get(user)
280
+ if not tunnel or not tunnel.get("ws"):
281
+ # The agent reconnects within ~2s of a dropped link (VPN flap, network
282
+ # change, service restart), so give it a moment instead of reporting the
283
+ # node as offline immediately. A missing agent is then a rare answer.
284
+ for _ in range(16): # up to ~8 seconds
285
+ await asyncio.sleep(0.5)
286
+ tunnel = active_tunnels.get(user)
287
+ if tunnel and tunnel.get("ws"):
288
+ break
289
+ if not tunnel or not tunnel.get("ws"):
290
+ return {
291
+ "exit_code": 1,
292
+ "stdout": "",
293
+ "stderr": (f"[Mesh Gateway] Node '{user}' did not come back within 8 seconds - the agent "
294
+ f"on the host is not connected. Start/restart the mesh agent there, then retry; "
295
+ f"this is a transport problem, not a missing file or permission.")
296
+ }
297
+ req_id = str(uuid.uuid4())
298
+ fut = asyncio.get_running_loop().create_future()
299
+ tunnel["pending"][req_id] = fut
300
+ payload = {
301
+ "id": req_id,
302
+ "method": "tools/call",
303
+ "params": {"name": name, "arguments": args}
304
+ }
305
+ try:
306
+ await tunnel["ws"].send_text(json.dumps(payload))
307
+ # 28s timeout: Agent runs commands with 25s timeout, so 28s gives 3s margin and returns cleanly
308
+ # BEFORE Google's 30s frontend timeout, completely preventing Gemini Error 1076
309
+ res_msg = await asyncio.wait_for(fut, timeout=28.0)
310
+ return res_msg.get("result", res_msg)
311
+ except asyncio.TimeoutError:
312
+ tunnel["pending"].pop(req_id, None)
313
+ return {"exit_code": 124, "error": f"Command timed out after 25s on node '{user}'"}
314
+ except Exception as e:
315
+ tunnel["pending"].pop(req_id, None)
316
+ # Self-heal: a dropped tunnel (network flap, agent restart) is transient.
317
+ # Wait for the agent to come back and replay the call once instead of
318
+ # failing the client's request.
319
+ logger.warning(f"Tunnel call to '{user}' failed ({e}); waiting for reconnect and retrying once")
320
+ for _ in range(20): # up to ~10 seconds
321
+ await asyncio.sleep(0.5)
322
+ fresh = active_tunnels.get(user)
323
+ if fresh and fresh.get("ws") and fresh.get("ws") is not tunnel.get("ws"):
324
+ try:
325
+ retry_id = str(uuid.uuid4())
326
+ retry_fut = asyncio.get_running_loop().create_future()
327
+ fresh["pending"][retry_id] = retry_fut
328
+ await fresh["ws"].send_text(json.dumps({"id": retry_id, "method": "tools/call",
329
+ "params": {"name": name, "arguments": args}}))
330
+ res_msg = await asyncio.wait_for(retry_fut, timeout=20.0)
331
+ logger.info(f"Retry after reconnect succeeded for '{user}'")
332
+ return res_msg.get("result", res_msg)
333
+ except Exception as retry_exc:
334
+ fresh["pending"].pop(retry_id, None)
335
+ logger.warning(f"Retry after reconnect failed for '{user}': {retry_exc}")
336
+ break
337
+ return {"exit_code": 1, "error": str(e)}
338
+
339
+ def remote_tool_error(res):
340
+ """Return a human-readable error string if a remote tool response is a failure, else None.
341
+
342
+ Covers three cases:
343
+ * the node returned {"error": "..."} (tool-level failure);
344
+ * the node returned {"ok": false, ...};
345
+ * the gateway itself could not reach the node / the tunnel broke, which yields
346
+ a bare {"exit_code": !=0, "stdout", "stderr"} with no tool payload.
347
+ A legitimate job_output payload always carries a "status" key, so it is never
348
+ mistaken for a transport failure even when the job's exit_code != 0.
349
+ """
350
+ if not isinstance(res, dict):
351
+ return "invalid response from node"
352
+ if res.get("error"):
353
+ return str(res.get("error"))
354
+ if res.get("ok") is False:
355
+ return str(res.get("message") or res.get("stderr") or "operation failed (ok=false)")
356
+ if res.get("exit_code") not in (None, 0) and set(res.keys()) <= {"exit_code", "stdout", "stderr"}:
357
+ return str(res.get("stderr") or res.get("stdout") or f"remote call failed with exit code {res.get('exit_code')}")
358
+ return None
359
+
360
+
361
+ async def health(request: Request):
362
+ if request.method == "OPTIONS":
363
+ return Response(status_code=204, headers={"Allow": "GET, HEAD, OPTIONS", "Access-Control-Allow-Origin": "*"})
364
+ user = get_target_user(request)
365
+ is_online = (user in active_tunnels) if user != "anonymous" else False
366
+ return JSONResponse({
367
+ "status": "healthy",
368
+ "service": "antigravity_mesh_gateway",
369
+ "host": PUBLIC_HOST,
370
+ "target_user": user,
371
+ "node_online": is_online,
372
+ "active_tunnels_count": len(active_tunnels),
373
+ # Diagnostics: an abandoned SSE stream keeps a queue alive, and a client
374
+ # with a connection cap would then be unable to open a new one.
375
+ "open_sse_streams": sum(len(q) for q in active_sse_subscribers.values()),
376
+ "streams_per_user": {u: len(q) for u, q in active_sse_subscribers.items()},
377
+ }, headers={"Access-Control-Allow-Origin": "*"})
378
+
379
+ async def api_register(request: Request):
380
+ if request.method == "OPTIONS":
381
+ return Response(status_code=204, headers={"Allow": "POST, OPTIONS", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*"})
382
+ try:
383
+ data = await request.json()
384
+ except Exception:
385
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
386
+
387
+ raw_user = data.get("username", "").strip()
388
+ clean_user = re.sub(r"[^a-z0-9_-]", "", raw_user.lower())
389
+ if not clean_user or len(clean_user) < 2:
390
+ return JSONResponse({"error": "Invalid username (min 2 alphanumeric chars)"}, status_code=400)
391
+
392
+ if clean_user in RESERVED_NAMES:
393
+ return JSONResponse({
394
+ "error": "reserved_username",
395
+ "message": f"Username '{clean_user}' is reserved for system services."
396
+ }, status_code=400)
397
+
398
+ raw_mac = data.get("mac_address", "").strip()
399
+ clean_mac = ""
400
+ if raw_mac:
401
+ mac_hex = re.sub(r"[^0-9a-fA-F]", "", raw_mac).lower()
402
+ if len(mac_hex) == 12:
403
+ clean_mac = ":".join(mac_hex[i:i+2] for i in range(0, 12, 2))
404
+ else:
405
+ clean_mac = raw_mac.lower()
406
+
407
+ clean_os = str(data.get("os", "")).strip()[:100]
408
+ registry = load_registry()
409
+ now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
410
+
411
+ # --- DEVICE FINGERPRINTING (MAC & OS) ---
412
+ # 1. Check if a device with this MAC address was already registered
413
+ if clean_mac and clean_mac != "00:00:00:00:00:00":
414
+ for u, entry in registry.items():
415
+ entry_mac = entry.get("mac_address", "").lower()
416
+ if entry_mac == clean_mac:
417
+ logger.info(f"Reusing existing node registration '{u}' for MAC {clean_mac}")
418
+ entry["last_seen"] = now_iso
419
+ if clean_os and not entry.get("os"):
420
+ entry["os"] = clean_os
421
+ save_registry(registry)
422
+ token = entry["token"]
423
+ return JSONResponse({
424
+ "status": "success",
425
+ "username": u,
426
+ "token": token,
427
+ "gateway": PUBLIC_HOST,
428
+ "sse_url": public_url("/sse", u, token),
429
+ "tunnel_url": f"wss://{PUBLIC_HOST}/ws/tunnel?user={u}&token={token}",
430
+ "reused": True,
431
+ "instructions": f"Antigravity Mesh node '{user}'. Direct access to the real remote host over a reverse tunnel. "
432
+ f"Do not guess paths or outputs: use list_dir/glob_find/grep_search/read_file/bash_exec/"
433
+ f"system_vitals. Use run_job for slow commands; page output with max_chars+cursor."
434
+ })
435
+
436
+ # 2. If not found by MAC, check if username already exists in registry
437
+ auto_suffix = data.get("auto_suffix", False)
438
+ if clean_user in registry:
439
+ req_token = request.headers.get("authorization", "").replace("Bearer ", "").strip() or data.get("token")
440
+ # If valid token provided, update device info for existing user
441
+ if req_token and tokens_equal(req_token, registry[clean_user]["token"]):
442
+ token = registry[clean_user]["token"]
443
+ if clean_mac:
444
+ registry[clean_user]["mac_address"] = clean_mac
445
+ if clean_os:
446
+ registry[clean_user]["os"] = clean_os
447
+ registry[clean_user]["last_seen"] = now_iso
448
+ save_registry(registry)
449
+ return JSONResponse({
450
+ "status": "success",
451
+ "username": clean_user,
452
+ "token": token,
453
+ "gateway": PUBLIC_HOST,
454
+ "sse_url": public_url("/sse", clean_user, token),
455
+ "tunnel_url": f"wss://{PUBLIC_HOST}/ws/tunnel?user={clean_user}&token={token}",
456
+ "reused": True,
457
+ "instructions": "Authenticated and updated existing user registration."
458
+ })
459
+ elif auto_suffix:
460
+ # Different device claiming same name -> generate unique suffix
461
+ suffix = 2
462
+ cand = f"{clean_user}-{suffix}"
463
+ while cand in registry or cand in RESERVED_NAMES:
464
+ suffix += 1
465
+ cand = f"{clean_user}-{suffix}"
466
+ clean_user = cand
467
+ token = secrets.token_hex(16)
468
+ registry[clean_user] = {
469
+ "token": token,
470
+ "created_at": now_iso,
471
+ "mode": "tunnel",
472
+ "status": "active",
473
+ "mac_address": clean_mac,
474
+ "os": clean_os
475
+ }
476
+ save_registry(registry)
477
+ else:
478
+ suffix = 2
479
+ cand = f"{clean_user}-{suffix}"
480
+ while cand in registry or cand in RESERVED_NAMES:
481
+ suffix += 1
482
+ cand = f"{clean_user}-{suffix}"
483
+ return JSONResponse({
484
+ "error": "conflict",
485
+ "message": f"User '{clean_user}' already exists. Provide token to retrieve config.",
486
+ "suggested_username": cand
487
+ }, status_code=409)
488
+ else:
489
+ # Brand new registration
490
+ token = secrets.token_hex(16)
491
+ registry[clean_user] = {
492
+ "token": token,
493
+ "created_at": now_iso,
494
+ "mode": "tunnel",
495
+ "status": "active",
496
+ "mac_address": clean_mac,
497
+ "os": clean_os
498
+ }
499
+ save_registry(registry)
500
+
501
+ return JSONResponse({
502
+ "status": "success",
503
+ "username": clean_user,
504
+ "token": token,
505
+ "gateway": PUBLIC_HOST,
506
+ "sse_url": public_url("/sse", clean_user, token),
507
+ "tunnel_url": f"wss://{PUBLIC_HOST}/ws/tunnel?user={clean_user}&token={token}",
508
+ "reused": False,
509
+ "instructions": "Add the sse_url to Google Gemini Web (Settings -> MCP)."
510
+ })
511
+
512
+ async def oauth_discovery(request: Request):
513
+ """RFC 9728 protected-resource metadata.
514
+
515
+ Returning 404 here made every MCP client log a connection error while
516
+ connecting (Gemini Spark probes this path twice). The mesh authenticates with
517
+ a bearer token in the URL, so the honest answer is metadata that advertises
518
+ no authorization server and no bearer requirement for discovery.
519
+ """
520
+ host = request.headers.get("host", PUBLIC_HOST)
521
+ base = f"https://{host}"
522
+ return JSONResponse(
523
+ {
524
+ "resource": base,
525
+ "authorization_servers": [],
526
+ "bearer_methods_supported": ["query", "header"],
527
+ "scopes_supported": [],
528
+ "resource_documentation": "https://github.com/LevRa7/Computer-use-for-Gemini-App-Web",
529
+ "resource_name": "Antigravity Mesh",
530
+ },
531
+ headers={
532
+ "Access-Control-Allow-Origin": "*",
533
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS, HEAD",
534
+ "Access-Control-Allow-Headers": "*",
535
+ "Cache-Control": "public, max-age=300",
536
+ },
537
+ )
538
+
539
+ async def sse_endpoint(request: Request):
540
+ ok, msg, user = verify_token(request)
541
+ if not ok:
542
+ return JSONResponse(
543
+ {"error": "unauthorized", "message": msg},
544
+ status_code=401,
545
+ headers={"Access-Control-Allow-Origin": "*"}
546
+ )
547
+
548
+ if request.method == "HEAD":
549
+ return Response(
550
+ status_code=200,
551
+ media_type="text/event-stream",
552
+ headers={
553
+ "Access-Control-Allow-Origin": "*",
554
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
555
+ "mcp-protocol-version": protocol_version_for(user),
556
+ }
557
+ )
558
+
559
+ token = get_request_token(request)
560
+ # Two different things are needed here, and conflating them broke delivery:
561
+ # * the id the CLIENT sees must match the one POST responses return, or the
562
+ # client believes it holds two sessions and aborts one;
563
+ # * the key this queue is stored under must be UNIQUE per connection,
564
+ # because Google's frontends load-balance requests across many IPs and
565
+ # any one of them may send DELETE. With a shared key that DELETE destroyed
566
+ # the stream another in-flight request was still reading, so its result
567
+ # never reached the model.
568
+ session_id = resolve_session_id(request, user)
569
+ stream_key = str(uuid.uuid4())
570
+ # The classic SSE transport announces where to POST. A Streamable HTTP client
571
+ # (GET /mcp) must receive JSON-RPC frames only: an `endpoint` frame pointing
572
+ # at a different path is not part of that protocol, and a strict client drops
573
+ # the stream over it - the stream it was reading tool results from.
574
+ announce_endpoint = announce_endpoint_for_path(request.url.path)
575
+ queue = asyncio.Queue()
576
+ if user not in active_sse_subscribers:
577
+ active_sse_subscribers[user] = set()
578
+ active_sse_subscribers[user].add(queue)
579
+ # stored under the per-connection key, not the shared client-visible id
580
+ active_sse_sessions[stream_key] = queue
581
+ logger.info(
582
+ f"SSE stream opened for '{user}' (stream_key={stream_key}, "
583
+ f"session={session_id[:12]}…, path={request.url.path}, "
584
+ f"open_now={len(active_sse_subscribers[user])})"
585
+ )
586
+
587
+ async def event_generator():
588
+ try:
589
+ if announce_endpoint:
590
+ yield f"event: endpoint\ndata: /messages?user={user}&token={token}\n\n"
591
+ while True:
592
+ try:
593
+ msg = await asyncio.wait_for(queue.get(), timeout=15.0)
594
+ if msg is None:
595
+ break
596
+ yield f"event: message\ndata: {msg}\n\n"
597
+ except asyncio.TimeoutError:
598
+ yield ": keepalive\n\n"
599
+ except asyncio.CancelledError:
600
+ logger.info(f"SSE stream cancelled for '{user}' (stream_key={stream_key})")
601
+ raise
602
+ finally:
603
+ active_sse_sessions.pop(stream_key, None)
604
+ if user in active_sse_subscribers and queue in active_sse_subscribers[user]:
605
+ active_sse_subscribers[user].remove(queue)
606
+ logger.info(f"SSE stream closed for '{user}' (stream_key={stream_key}, "
607
+ f"open_now={len(active_sse_subscribers.get(user, ()))})")
608
+
609
+ return StreamingResponse(
610
+ event_generator(),
611
+ media_type="text/event-stream",
612
+ headers={
613
+ "Cache-Control": "no-cache",
614
+ "Connection": "keep-alive",
615
+ "X-Accel-Buffering": "no",
616
+ "Access-Control-Allow-Origin": "*",
617
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
618
+ "mcp-session-id": session_id,
619
+ "mcp-protocol-version": protocol_version_for(user),
620
+ }
621
+ )
622
+
623
+ async def messages_endpoint(request: Request):
624
+ ok, err_msg, user = verify_token(request)
625
+ if not ok:
626
+ return JSONResponse(
627
+ {"error": "unauthorized", "message": err_msg},
628
+ status_code=401,
629
+ headers={
630
+ "Access-Control-Allow-Origin": "*",
631
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
632
+ "mcp-protocol-version": protocol_version_for(user),
633
+ }
634
+ )
635
+
636
+ try:
637
+ body = await request.json()
638
+ except Exception:
639
+ body = {}
640
+
641
+ method = body.get("method")
642
+ params = body.get("params", {})
643
+ req_id = body.get("id")
644
+
645
+ if req_id is None and method:
646
+ session_id = resolve_session_id(request, user)
647
+ return Response(
648
+ status_code=204,
649
+ headers={
650
+ "mcp-session-id": session_id,
651
+ "mcp-protocol-version": protocol_version_for(user),
652
+ "Access-Control-Allow-Origin": "*",
653
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
654
+ }
655
+ )
656
+
657
+ client_ip = request.client.host if request.client else "unknown"
658
+ call_started = time.monotonic()
659
+ # What the client accepts decides how the answer must be framed: a strict
660
+ # streamable-HTTP client that asks only for text/event-stream rejects a plain
661
+ # application/json body even when the payload is correct.
662
+ accept_hdr = (request.headers.get("accept") or "").lower()
663
+ incoming_session = request.headers.get("mcp-session-id") or ""
664
+ proto_hdr = request.headers.get("mcp-protocol-version") or ""
665
+ tool_note = ""
666
+ if method == "tools/call":
667
+ # Log which tool the client asked for: without it a retry loop in the
668
+ # client is impossible to diagnose from the gateway side.
669
+ try:
670
+ tool_note = ", tool=%s, args=%s" % (
671
+ params.get("name"),
672
+ json.dumps(params.get("arguments", {}), ensure_ascii=False)[:120],
673
+ )
674
+ except Exception:
675
+ tool_note = ", tool=?"
676
+ logger.info(f"Incoming MCP RPC from {client_ip} [{user}]: method={method}, id={req_id}{tool_note}"
677
+ + (f", accept={accept_hdr[:60]!r}" if accept_hdr else "")
678
+ + (f", session={incoming_session[:40]!r}" if incoming_session else ", session=<none>")
679
+ + (f", proto-hdr={proto_hdr[:20]!r}" if proto_hdr else ""))
680
+
681
+ resp = {"jsonrpc": "2.0", "id": req_id}
682
+ issued_session_id = "" # set by initialize; echoed in the response header below
683
+
684
+ if method == "initialize":
685
+ # Protocol version negotiation. The spec says a client MUST disconnect
686
+ # when the server answers with a version it does not support, and we used
687
+ # to hard-code 2024-11-05 whatever the client asked for. Echo the client's
688
+ # version when we support it, otherwise fall back to our baseline.
689
+ client_version = str((params or {}).get("protocolVersion") or "")
690
+ supported = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
691
+ if client_version in supported:
692
+ negotiated = client_version
693
+ elif re.fullmatch(r"\d{4}-\d{2}-\d{2}", client_version):
694
+ # The tool surface (initialize / tools list+call / ping) is stable
695
+ # across revisions. Answering an older version than the client asked
696
+ # for makes a compliant client DISCONNECT immediately - that was the
697
+ # real cause of the session churn - so echo any dated version rather
698
+ # than lose the session.
699
+ negotiated = client_version
700
+ else:
701
+ negotiated = "2024-11-05"
702
+ active_protocol_versions[user] = negotiated
703
+ session_id = issue_session_id(user)
704
+ issued_session_id = session_id
705
+ logger.info(f"initialize: client requested protocolVersion={client_version!r} "
706
+ f"clientInfo={(params or {}).get('clientInfo')} -> answering {negotiated}; "
707
+ f"issued session {session_id[:12]}… for user '{user}'")
708
+ resp["result"] = {
709
+ "protocolVersion": negotiated,
710
+ "capabilities": {
711
+ "tools": {"listChanged": False},
712
+ "prompts": {"listChanged": False},
713
+ "resources": {"subscribe": False, "listChanged": False}
714
+ },
715
+ "serverInfo": {
716
+ "name": "antigravity_mesh",
717
+ "version": "1.0.0"
718
+ },
719
+ "instructions": (
720
+ (
721
+ (
722
+ f"Antigravity Mesh node '{user}' - direct access to the real host over a reverse tunnel.\n"
723
+ f"GROUNDING (mandatory): NEVER state a path, file, directory or process you have not seen "
724
+ f"in a tool result in THIS conversation. Tool output marked [VERIFIED BY TOOL] is real; "
725
+ f"anything marked [TOOL ERROR] means the path does NOT exist - never repeat it as real. "
726
+ f"If you have not inspected something, say so and call list_dir / glob_find / grep_search first.\n"
727
+ f"USE: list_dir/glob_find/grep_search to locate, read_file to read, write_file/edit_file to "
728
+ f"modify, bash_exec for short commands, system_vitals for resources. Only touch paths you "
729
+ f"have confirmed. For slow work (builds, installs, tests, downloads) use run_job, then "
730
+ f"job_output / job_kill / job_list. Long output is paged: pass cursor=<next_cursor>.\n"
731
+ f"REACHABILITY: every tool result you receive in this conversation was produced ON the "
732
+ f"node, so the node IS online. After any successful tool result, never tell the user the "
733
+ f"host is unreachable or that the agent did not answer. If a call fails, report its exact "
734
+ f"error text instead of guessing that the host is down."
735
+ )
736
+ )
737
+ )
738
+ }
739
+ elif method == "notifications/initialized":
740
+ session_id = resolve_session_id(request, user)
741
+ return Response(
742
+ status_code=204,
743
+ headers={
744
+ "mcp-session-id": session_id,
745
+ "mcp-protocol-version": protocol_version_for(user),
746
+ "Access-Control-Allow-Origin": "*",
747
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
748
+ }
749
+ )
750
+ elif method == "ping":
751
+ resp["result"] = {}
752
+ elif method == "tools/list":
753
+ resp["result"] = {
754
+ "tools": [
755
+ {
756
+ "name": "list_dir",
757
+ "description": (
758
+ "List a directory on the remote host (default: workspace)."
759
+ ),
760
+ "inputSchema": {
761
+ "type": "object",
762
+ "properties": {
763
+ "path": {
764
+ "type": "string",
765
+ "description": "Optional directory path to inspect. If omitted or empty, lists the agent's current working directory."
766
+ }
767
+ },
768
+ "required": []
769
+ }
770
+ },
771
+ {
772
+ "name": "read_file",
773
+ "description": (
774
+ "Read a file, line-numbered. Page with max_chars/cursor."
775
+ ),
776
+ "inputSchema": {
777
+ "type": "object",
778
+ "properties": {
779
+ "path": {"type": "string", "description": "File path to read"},
780
+ "start_line": {"type": "integer", "description": "First line (1-based)"},
781
+ "end_line": {"type": "integer", "description": "Last line (1-based)"}
782
+ },
783
+ "required": ["path"]
784
+ }
785
+ },
786
+ {
787
+ "name": "bash_exec",
788
+ "description": (
789
+ f"Execute a shell command directly on {user}'s host machine. "
790
+ "MANDATORY: Always use this tool to inspect git status, run builds, check processes, or execute tasks. "
791
+ "NEVER guess command outputs."
792
+ ),
793
+ "inputSchema": {
794
+ "type": "object",
795
+ "properties": {
796
+ "command": {
797
+ "type": "string",
798
+ "description": "Shell command to execute on the node"
799
+ }
800
+ },
801
+ "required": ["command"]
802
+ }
803
+ },
804
+ {
805
+ "name": "mesh_status",
806
+ "description": ("Confirm this node is reachable. Call this FIRST if you believe the "
807
+ "host is offline: it returns live evidence from the host."),
808
+ "inputSchema": {"type": "object", "properties": {}, "required": []}
809
+ },
810
+ {
811
+ "name": "system_info",
812
+ "description": ("One-call host summary: OS, desktop, user, home, disks, memory, load, "
813
+ "top processes and the current wallpaper. Prefer this over several "
814
+ "exploratory commands - the client allows only a few calls per turn."),
815
+ "inputSchema": {"type": "object", "properties": {}, "required": []}
816
+ },
817
+ {
818
+ "name": "system_vitals",
819
+ "description": f"Retrieve real-time CPU, RAM, and Disk metrics on {user}'s host machine",
820
+ "inputSchema": {"type": "object", "properties": {}, "required": []}
821
+ },
822
+ {
823
+ "name": "get_orchestration_skill",
824
+ "description": "Load the node orchestration rules.",
825
+ "inputSchema": {"type": "object", "properties": {}, "required": []}
826
+ },
827
+ {
828
+ "name": "write_file",
829
+ "description": (
830
+ "Write a file atomically (creates parent dirs)."
831
+ ),
832
+ "inputSchema": {
833
+ "type": "object",
834
+ "properties": {
835
+ "path": {"type": "string", "description": "Absolute path, or relative to the agent's workspace"},
836
+ "content": {"type": "string", "description": "Exact file content to write"},
837
+ "create_dirs": {"type": "boolean", "description": "Create parent directories"},
838
+ "mode": {"type": "string", "description": "File mode, e.g. 0644"}
839
+ },
840
+ "required": ["path", "content"]
841
+ }
842
+ },
843
+ {
844
+ "name": "edit_file",
845
+ "description": (
846
+ "Replace an exact string in a file; old_string must be unique."
847
+ ),
848
+ "inputSchema": {
849
+ "type": "object",
850
+ "properties": {
851
+ "path": {"type": "string", "description": "File to edit"},
852
+ "old_string": {"type": "string", "description": "Exact text to replace (not a regex); must be unique unless replace_all is true"},
853
+ "new_string": {"type": "string", "description": "Replacement text"},
854
+ "expected_sha256": {"type": "string", "description": "Fail if file changed since read"},
855
+ "replace_all": {"type": "boolean", "description": "Replace every occurrence"}
856
+ },
857
+ "required": ["path", "old_string", "new_string"]
858
+ }
859
+ },
860
+ {
861
+ "name": "grep_search",
862
+ "description": (
863
+ "Recursively search file contents; returns path:line: text."
864
+ ),
865
+ "inputSchema": {
866
+ "type": "object",
867
+ "properties": {
868
+ "pattern": {"type": "string", "description": "Text or regular expression to search for"},
869
+ "path": {"type": "string", "description": "Directory to search recursively (default \".\")"},
870
+ "glob": {"type": "string", "description": "Filename filter"},
871
+ "limit": {"type": "integer", "description": "Max results"},
872
+ "ignore_case": {"type": "boolean", "description": "Case-insensitive"},
873
+ "fixed": {"type": "boolean", "description": "Literal text, not regex"},
874
+ "context": {"type": "integer", "description": "Context lines"}
875
+ },
876
+ "required": ["pattern"]
877
+ }
878
+ },
879
+ {
880
+ "name": "glob_find",
881
+ "description": (
882
+ "Find files by glob pattern (* and **)."
883
+ ),
884
+ "inputSchema": {
885
+ "type": "object",
886
+ "properties": {
887
+ "pattern": {"type": "string", "description": "Glob pattern, e.g. \"**/*.py\" or \"src/*.json\""},
888
+ "path": {"type": "string", "description": "Base directory to search from (default \".\")"}
889
+ },
890
+ "required": ["pattern"]
891
+ }
892
+ },
893
+ {
894
+ "name": "run_job",
895
+ "description": (
896
+ "Start a long command in the background; returns job_id."
897
+ ),
898
+ "inputSchema": {
899
+ "type": "object",
900
+ "properties": {
901
+ "command": {"type": "string", "description": "Shell command to run in the background"},
902
+ "cwd": {"type": "string", "description": "Optional working directory"}
903
+ },
904
+ "required": ["command"]
905
+ }
906
+ },
907
+ {
908
+ "name": "job_output",
909
+ "description": (
910
+ "Get a job status/output. wait_ms<=20000; page via next_cursor."
911
+ ),
912
+ "inputSchema": {
913
+ "type": "object",
914
+ "properties": {
915
+ "job_id": {"type": "string", "description": "Job id returned by run_job"},
916
+ "wait_ms": {"type": "integer", "description": "Block up to this long (max 20000)"},
917
+ "max_chars": {"type": "integer", "description": "Chunk size"},
918
+ "cursor": {"type": "integer", "description": "Offset to continue from"}
919
+ },
920
+ "required": ["job_id"]
921
+ }
922
+ },
923
+ {
924
+ "name": "job_kill",
925
+ "description": "Stop a running job.",
926
+ "inputSchema": {
927
+ "type": "object",
928
+ "properties": {
929
+ "job_id": {"type": "string", "description": "Job id to stop"},
930
+ "signal": {"type": "string", "description": "Signal to send, e.g. TERM or KILL (default TERM)"}
931
+ },
932
+ "required": ["job_id"]
933
+ }
934
+ },
935
+ {
936
+ "name": "job_list",
937
+ "description": "List recent jobs, newest first (20 by default).",
938
+ "inputSchema": {"type": "object", "properties": {
939
+ "limit": {"type": "integer", "description": "How many jobs to return (1-50, default 20)"}
940
+ }, "required": []}
941
+ }
942
+ ]
943
+ }
944
+ elif method == "tools/call":
945
+ name = params.get("name")
946
+ args = params.get("arguments", {})
947
+ is_error = False
948
+ if name == "get_orchestration_skill":
949
+ content_text = get_skill(user, request.headers.get("host", PUBLIC_HOST))
950
+ elif name == "mesh_status":
951
+ res = await call_remote_tool(user, name, args)
952
+ err = remote_tool_error(res)
953
+ if err:
954
+ is_error = True
955
+ content_text = f"[Error] {err}"
956
+ else:
957
+ parts = ["[NODE REACHABLE] This answer was produced on the host itself - the mesh "
958
+ "agent and the tunnel are working. Do not claim the node is offline."]
959
+ for key in ("hostname", "agent_pid", "checked_at", "host_uptime_human", "workspace"):
960
+ if res.get(key) is not None:
961
+ parts.append(f"{key}: {res.get(key)}")
962
+ content_text = "\n".join(parts)
963
+ elif name == "system_info":
964
+ res = await call_remote_tool(user, name, args)
965
+ err = remote_tool_error(res)
966
+ if err:
967
+ is_error = True
968
+ content_text = f"[Error] {err}"
969
+ else:
970
+ parts = []
971
+ for key in ("hostname", "os", "kernel", "user", "home", "desktop",
972
+ "session_type", "wallpaper", "wallpaper_exists"):
973
+ value = res.get(key)
974
+ if value not in (None, ""):
975
+ parts.append("%s: %s" % (key, value))
976
+ for key in ("memory", "load", "disks", "top_processes"):
977
+ if res.get(key):
978
+ parts.append("\n[%s]\n%s" % (key, res.get(key)))
979
+ content_text = "\n".join(parts) if parts else json.dumps(res, ensure_ascii=False, indent=2)
980
+ elif name == "system_vitals":
981
+ res = await call_remote_tool(user, name, args)
982
+ if "error" in res:
983
+ is_error = True
984
+ content_text = f"Error: {res.get('error')}"
985
+ else:
986
+ content_text = json.dumps(res, ensure_ascii=False, indent=2)
987
+ elif name in ("bash_exec", "list_dir", "read_file"):
988
+ # Gateway-level guard: call_remote_tool gives up after 28s, so never forward
989
+ # a bash timeout longer than 25s (node may default to 25 anyway).
990
+ if name == "bash_exec":
991
+ timeout_sec = args.get("timeout_sec")
992
+ if isinstance(timeout_sec, (int, float)) and timeout_sec > 25:
993
+ args = dict(args)
994
+ args["timeout_sec"] = 25
995
+ res = await call_remote_tool(user, name, args)
996
+ if "error" in res:
997
+ is_error = True
998
+ content_text = f"[Execution Error]: {res.get('error')}"
999
+ else:
1000
+ exit_code = res.get("exit_code", 0)
1001
+ # Never strip: a paginated chunk must survive byte-exact so the
1002
+ # client can rebuild the output by following next_cursor.
1003
+ stdout = res.get("stdout", "")
1004
+ stderr = res.get("stderr", "")
1005
+ if exit_code != 0:
1006
+ is_error = True
1007
+ # A non-zero exit code is the command's own result, not a
1008
+ # transport failure: the command RAN on the host. Without this
1009
+ # wording a model reads isError=true as "the node is
1010
+ # unreachable" and stops looking for the real output.
1011
+ parts = [
1012
+ f"[Exit code: {exit_code}] The command ran on the host and the node "
1013
+ f"is reachable; {exit_code} is the command's own exit status."
1014
+ ]
1015
+ if stderr.strip():
1016
+ parts.append(stderr)
1017
+ if stdout.strip():
1018
+ parts.append(stdout)
1019
+ content_text = "\n".join(parts) if parts else f"[Exit code: {exit_code}]"
1020
+ else:
1021
+ parts = []
1022
+ if stdout:
1023
+ parts.append(stdout)
1024
+ if stderr:
1025
+ parts.append(f"[STDERR]\n{stderr}")
1026
+ content_text = "\n\n".join(parts) if parts else "(Command executed successfully with no output)"
1027
+ # Surface node-side pagination/spooling so the model knows there is more.
1028
+ if isinstance(res, dict):
1029
+ if res.get("next_cursor") is not None:
1030
+ content_text += (
1031
+ f"\n\n[output continues on the node: call {name} again with "
1032
+ f"cursor={res.get('next_cursor')} (optionally a larger max_chars)]"
1033
+ )
1034
+ if res.get("saved_to"):
1035
+ content_text += f"\n\n[full output saved on the node at {res['saved_to']}]"
1036
+ elif name in ("write_file", "edit_file"):
1037
+ res = await call_remote_tool(user, name, args)
1038
+ err = remote_tool_error(res)
1039
+ if err:
1040
+ is_error = True
1041
+ content_text = f"[Error] {err}"
1042
+ else:
1043
+ path = res.get("path", args.get("path", ""))
1044
+ sha = res.get("sha256", "")
1045
+ if name == "write_file":
1046
+ content_text = f"OK: wrote {res.get('bytes', 0)} bytes to {path} (sha256={sha})"
1047
+ else:
1048
+ content_text = f"OK: edited {path} ({res.get('replacements', 0)} replacement(s), sha256={sha})"
1049
+ elif name == "grep_search":
1050
+ res = await call_remote_tool(user, name, args)
1051
+ err = remote_tool_error(res)
1052
+ if err:
1053
+ is_error = True
1054
+ content_text = f"[Error] {err}"
1055
+ else:
1056
+ matches = res.get("matches") or []
1057
+ if not matches:
1058
+ content_text = "No matches."
1059
+ else:
1060
+ lines = []
1061
+ for m in matches:
1062
+ if not isinstance(m, dict):
1063
+ continue
1064
+ lines.append(f"{m.get('path')}:{m.get('line')}: {m.get('text')}")
1065
+ content_text = "\n".join(lines) if lines else "No matches."
1066
+ if res.get("truncated"):
1067
+ content_text += f"\n\n(truncated: showing first {len(lines)} matches; narrow the pattern or raise limit)"
1068
+ elif name == "glob_find":
1069
+ res = await call_remote_tool(user, name, args)
1070
+ err = remote_tool_error(res)
1071
+ if err:
1072
+ is_error = True
1073
+ content_text = f"[Error] {err}"
1074
+ else:
1075
+ files = res.get("files") or []
1076
+ content_text = "\n".join(str(f) for f in files) if files else "No files found."
1077
+ if res.get("truncated"):
1078
+ content_text += f"\n\n(truncated: showing first {len(files)} paths)"
1079
+ elif name == "run_job":
1080
+ res = await call_remote_tool(user, name, args)
1081
+ err = remote_tool_error(res)
1082
+ if err:
1083
+ is_error = True
1084
+ content_text = f"[Error] {err}"
1085
+ else:
1086
+ job_id = res.get("job_id", "")
1087
+ content_text = (
1088
+ f"Started job {job_id} (pid {res.get('pid')})\n"
1089
+ f"command: {res.get('command', args.get('command', ''))}\n"
1090
+ f"Use job_output(job_id=\"{job_id}\") to poll progress; use job_kill(job_id=\"{job_id}\") to stop it."
1091
+ )
1092
+ elif name == "job_output":
1093
+ # Gateway-level guard: call_remote_tool gives up after 28s, keep wait below it.
1094
+ wait_ms = args.get("wait_ms")
1095
+ if isinstance(wait_ms, (int, float)) and wait_ms > 20000:
1096
+ args = dict(args)
1097
+ args["wait_ms"] = 20000
1098
+ res = await call_remote_tool(user, name, args)
1099
+ err = remote_tool_error(res)
1100
+ if err:
1101
+ is_error = True
1102
+ content_text = f"[Error] {err}"
1103
+ else:
1104
+ parts = [f"[status={res.get('status')} exit_code={res.get('exit_code')} duration={res.get('duration')}]"]
1105
+ stdout = res.get("stdout") or ""
1106
+ stderr = res.get("stderr") or ""
1107
+ if stdout:
1108
+ parts.append(stdout)
1109
+ if stderr:
1110
+ parts.append(f"[STDERR]\n{stderr}")
1111
+ if res.get("next_cursor") is not None:
1112
+ parts.append(f"... output truncated; continue with cursor={res.get('next_cursor')}")
1113
+ content_text = "\n\n".join(parts)
1114
+ elif name == "job_kill":
1115
+ res = await call_remote_tool(user, name, args)
1116
+ err = remote_tool_error(res)
1117
+ if err:
1118
+ is_error = True
1119
+ content_text = f"[Error] {err}"
1120
+ else:
1121
+ content_text = f"Job {args.get('job_id', '')}: status={res.get('status')} (ok={res.get('ok')})"
1122
+ elif name == "job_list":
1123
+ res = await call_remote_tool(user, name, args)
1124
+ err = remote_tool_error(res)
1125
+ if err:
1126
+ is_error = True
1127
+ content_text = f"[Error] {err}"
1128
+ else:
1129
+ jobs = res.get("jobs") or []
1130
+ if not jobs:
1131
+ content_text = "No jobs."
1132
+ else:
1133
+ lines = ["job_id | status | exit_code | duration | started_at | command"]
1134
+ for j in jobs:
1135
+ if not isinstance(j, dict):
1136
+ continue
1137
+ lines.append(
1138
+ "{job_id} | {status} | {exit_code} | {duration} | {started_at} | {command}".format(
1139
+ job_id=j.get("job_id", ""),
1140
+ status=j.get("status", ""),
1141
+ exit_code=j.get("exit_code"),
1142
+ duration=j.get("duration"),
1143
+ started_at=j.get("started_at", ""),
1144
+ command=j.get("command", ""),
1145
+ )
1146
+ )
1147
+ content_text = "\n".join(lines)
1148
+ else:
1149
+ resp["error"] = {"code": -32601, "message": f"Unknown tool: {name}"}
1150
+ broadcast_sse(user, resp)
1151
+ session_id = resolve_session_id(request, user)
1152
+ return JSONResponse(
1153
+ resp,
1154
+ headers={
1155
+ "mcp-session-id": session_id,
1156
+ "mcp-protocol-version": protocol_version_for(user),
1157
+ "Access-Control-Allow-Origin": "*",
1158
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
1159
+ }
1160
+ )
1161
+
1162
+ # Size guard for the Gemini Web turn budget. The node already paginates
1163
+ # via next_cursor, so the gateway must NOT cut the middle out: that would
1164
+ # break byte-exact continuation. Keep the head and say how to get the
1165
+ # rest, so the model can ask for the next page instead of losing data.
1166
+ MAX_GATEWAY_TEXT = 45000
1167
+ if len(content_text) > MAX_GATEWAY_TEXT:
1168
+ kept = content_text[:MAX_GATEWAY_TEXT]
1169
+ omitted = len(content_text) - MAX_GATEWAY_TEXT
1170
+ content_text = (
1171
+ f"{kept}\n\n... [Output truncated by the gateway: {omitted} more characters. "
1172
+ f"Do not re-run the command; the node can page the rest: call the same tool again "
1173
+ f"with cursor=<next_cursor> and a larger max_chars, or read the saved file if the "
1174
+ f"result reported one.] ..."
1175
+ )
1176
+
1177
+ resp["result"] = {
1178
+ "content": [{"type": "text", "text": content_text}],
1179
+ "isError": is_error
1180
+ }
1181
+ elif method == "prompts/list":
1182
+ resp["result"] = {
1183
+ "prompts": [{"name": "antigravity-orchestrator", "description": f"Orchestrator role for {user}"}]
1184
+ }
1185
+ elif method == "prompts/get":
1186
+ skill_text = get_skill(user, request.headers.get("host", PUBLIC_HOST))
1187
+ resp["result"] = {
1188
+ "description": "Antigravity Orchestrator Persona",
1189
+ "messages": [{"role": "user", "content": {"type": "text", "text": skill_text}}]
1190
+ }
1191
+ elif method == "resources/list":
1192
+ resp["result"] = {
1193
+ "resources": [{
1194
+ "uri": "resource://skills/orchestrator.md",
1195
+ "name": "orchestrator.md",
1196
+ "mimeType": "text/markdown"
1197
+ }]
1198
+ }
1199
+ elif method == "resources/read":
1200
+ uri = params.get("uri")
1201
+ if uri == "resource://skills/orchestrator.md":
1202
+ text = get_skill(user, request.headers.get("host", PUBLIC_HOST))
1203
+ resp["result"] = {"contents": [{"uri": uri, "mimeType": "text/markdown", "text": text}]}
1204
+ else:
1205
+ resp["error"] = {"code": -32602, "message": f"Resource not found: {uri}"}
1206
+ broadcast_sse(user, resp)
1207
+ session_id = resolve_session_id(request, user)
1208
+ return JSONResponse(
1209
+ resp,
1210
+ headers={
1211
+ "mcp-session-id": session_id,
1212
+ "mcp-protocol-version": protocol_version_for(user),
1213
+ "Access-Control-Allow-Origin": "*",
1214
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
1215
+ }
1216
+ )
1217
+ else:
1218
+ resp["error"] = {"code": -32601, "message": f"Method not found: {method}"}
1219
+
1220
+ # Delivery channel. The answer always goes in the HTTP body; duplicating it on
1221
+ # the caller's stream is what makes a strict client see one response id twice
1222
+ # and drop the session. Only the legacy transports (POST /messages, or
1223
+ # LEGACY_SSE which answers 202 with no body) are delivered on the stream.
1224
+ path = request.url.path
1225
+ if should_broadcast_response(path):
1226
+ broadcast_sse(user, resp)
1227
+ else:
1228
+ logger.info(f"response [{method} id={req_id}] answered in the POST body only "
1229
+ f"(no stream duplicate)")
1230
+
1231
+ if method == "tools/call":
1232
+ # Duration matters: Gemini's frontend gives a tool call roughly 30 s, so a
1233
+ # slow command shows up here before it shows up as a client-side timeout.
1234
+ logger.info(f"tools/call {params.get('name')} took {time.monotonic() - call_started:.2f}s")
1235
+
1236
+ # Return response in HTTP body (for Streamable HTTP clients) with Mcp-Session-Id header.
1237
+ # A client that asks ONLY for text/event-stream must get an SSE-framed body:
1238
+ # a strict streamable-HTTP client rejects application/json even when the
1239
+ # payload itself is correct.
1240
+ # The id we issued at initialize must be the one the client sees here, or the
1241
+ # client believes it holds a different session than the one it opened.
1242
+ session_id = issued_session_id or resolve_session_id(request, user)
1243
+ headers = {
1244
+ "mcp-session-id": session_id,
1245
+ "mcp-protocol-version": protocol_version_for(user),
1246
+ "Access-Control-Allow-Origin": "*",
1247
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
1248
+ }
1249
+ wants_sse_only = ("text/event-stream" in accept_hdr) and ("application/json" not in accept_hdr)
1250
+ if wants_sse_only:
1251
+ payload = "event: message\ndata: %s\n\n" % json.dumps(resp, ensure_ascii=False)
1252
+ headers["Cache-Control"] = "no-cache"
1253
+ return Response(payload, media_type="text/event-stream", headers=headers)
1254
+
1255
+ if (LEGACY_SSE and method != "initialize"
1256
+ and "text/event-stream" in accept_hdr and active_sse_subscribers.get(user)):
1257
+ # The client is holding a stream and reads the answer there, so a body
1258
+ # would be a second copy of the same response. 202 Accepted with no body
1259
+ # is what the legacy SSE transport prescribes; the broadcast above has
1260
+ # already queued the message on the stream.
1261
+ #
1262
+ # `initialize` is deliberately excluded: with it answered 202 the client
1263
+ # never learns the session id or the negotiated version, and the observed
1264
+ # result was a pure initialise/DELETE loop with no tool call at all.
1265
+ logger.info(f"legacy-SSE mode: answering {method} with 202, result goes to the stream")
1266
+ return Response(status_code=202, headers=headers)
1267
+
1268
+ return JSONResponse(resp, headers=headers)
1269
+
1270
+ async def mcp_unified_endpoint(request: Request):
1271
+ if request.method == "OPTIONS":
1272
+ return Response(
1273
+ status_code=204,
1274
+ headers={
1275
+ "Allow": "GET, POST, DELETE, OPTIONS, HEAD",
1276
+ "Access-Control-Allow-Origin": "*",
1277
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS, HEAD",
1278
+ "Access-Control-Allow-Headers": "*",
1279
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
1280
+ "mcp-protocol-version": protocol_version_for(user),
1281
+ }
1282
+ )
1283
+ if request.method == "DELETE":
1284
+ ok, msg, user = verify_token(request)
1285
+ if not ok:
1286
+ return JSONResponse(
1287
+ {"error": "unauthorized", "message": msg},
1288
+ status_code=401,
1289
+ headers={
1290
+ "Access-Control-Allow-Origin": "*",
1291
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
1292
+ "mcp-protocol-version": protocol_version_for(user),
1293
+ }
1294
+ )
1295
+ client_ip = request.client.host if request.client else "unknown"
1296
+ session_id = resolve_session_id(request, user)
1297
+ logger.info(f"Session teardown (DELETE) from {client_ip} [{user}] session_id={session_id[:12]}…")
1298
+
1299
+ # Forget the session we issued for this connection. Streams live under
1300
+ # their own per-connection key, so a teardown can never close a stream
1301
+ # that another in-flight request is still reading.
1302
+ active_sessions.pop(session_id, None)
1303
+
1304
+ # Clean up session queue if active
1305
+ if session_id in active_sse_sessions:
1306
+ q = active_sse_sessions.pop(session_id, None)
1307
+ if q:
1308
+ try:
1309
+ q.put_nowait(None)
1310
+ except Exception:
1311
+ pass
1312
+
1313
+ return Response(
1314
+ status_code=204,
1315
+ headers={
1316
+ "mcp-session-id": session_id,
1317
+ "mcp-protocol-version": protocol_version_for(user),
1318
+ "Access-Control-Allow-Origin": "*",
1319
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS, HEAD",
1320
+ "Access-Control-Allow-Headers": "*",
1321
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
1322
+ }
1323
+ )
1324
+ if request.method in ("GET", "HEAD"):
1325
+ path = request.url.path
1326
+ if path == "/messages":
1327
+ ok, msg, user = verify_token(request)
1328
+ if not ok:
1329
+ return JSONResponse(
1330
+ {"error": "unauthorized", "message": msg},
1331
+ status_code=401,
1332
+ headers={
1333
+ "Access-Control-Allow-Origin": "*",
1334
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
1335
+ "mcp-protocol-version": protocol_version_for(user),
1336
+ }
1337
+ )
1338
+ accept = (request.headers.get("accept") or "").lower()
1339
+ client_ip = request.client.host if request.client else "unknown"
1340
+ logger.info(
1341
+ f"GET /messages from {client_ip} [{user}] accept={accept[:60]!r} "
1342
+ f"-> {'sse-stream' if 'text/event-stream' in accept else 'json-ready'}"
1343
+ )
1344
+ # Some MCP clients (Gemini Spark among them) open the message endpoint
1345
+ # itself as the server->client stream instead of /sse. Answering that
1346
+ # with a small JSON body closes the stream immediately, which makes the
1347
+ # client drop the session and re-initialise in a loop. Serve a real SSE
1348
+ # stream when the caller asks for one, and keep the JSON readiness probe
1349
+ # for plain health checks.
1350
+ if "text/event-stream" in accept:
1351
+ return await sse_endpoint(request)
1352
+ return JSONResponse({
1353
+ "status": "ready",
1354
+ "endpoint": "messages",
1355
+ "user": user
1356
+ }, headers={
1357
+ "Access-Control-Allow-Origin": "*",
1358
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
1359
+ "mcp-protocol-version": protocol_version_for(user)
1360
+ })
1361
+ return await sse_endpoint(request)
1362
+ if request.method == "POST":
1363
+ return await messages_endpoint(request)
1364
+ return Response(
1365
+ status_code=405,
1366
+ headers={
1367
+ "Allow": "GET, POST, DELETE, OPTIONS, HEAD",
1368
+ "Access-Control-Allow-Origin": "*",
1369
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
1370
+ "mcp-protocol-version": protocol_version_for(user),
1371
+ }
1372
+ )
1373
+
1374
+ async def ws_tunnel_endpoint(websocket: WebSocket):
1375
+ query = websocket.query_params
1376
+ user = query.get("user", "").lower()
1377
+ token = query.get("token", "")
1378
+ registry = load_registry()
1379
+
1380
+ if not user or user not in registry or not tokens_equal(token, registry[user].get("token", "")):
1381
+ await websocket.close(code=4001, reason="Unauthorized")
1382
+ return
1383
+
1384
+ # Cleanly terminate any prior websocket and fail pending futures for this user
1385
+ old_tunnel = active_tunnels.get(user)
1386
+ if old_tunnel:
1387
+ for r_id, fut in list(old_tunnel.get("pending", {}).items()):
1388
+ if not fut.done():
1389
+ fut.set_exception(ConnectionResetError("Tunnel reconnected"))
1390
+ if old_tunnel.get("ws"):
1391
+ try:
1392
+ await old_tunnel["ws"].close(code=1000, reason="Superceded by new connection")
1393
+ except Exception:
1394
+ pass
1395
+
1396
+ await websocket.accept()
1397
+ logger.info(f"Agent tunnel connected for user '{user}'")
1398
+ tunnel_data = {"ws": websocket, "pending": {}}
1399
+ active_tunnels[user] = tunnel_data
1400
+
1401
+ try:
1402
+ while True:
1403
+ raw = await websocket.receive_text()
1404
+ try:
1405
+ msg = json.loads(raw)
1406
+ except Exception:
1407
+ continue
1408
+ req_id = msg.get("id")
1409
+ if req_id and req_id in tunnel_data["pending"]:
1410
+ fut = tunnel_data["pending"].pop(req_id)
1411
+ if not fut.done():
1412
+ fut.set_result(msg)
1413
+ except WebSocketDisconnect:
1414
+ logger.info(f"Agent tunnel disconnected for user '{user}'")
1415
+ finally:
1416
+ if active_tunnels.get(user, {}).get("ws") == websocket:
1417
+ active_tunnels.pop(user, None)
1418
+
1419
+ routes = [
1420
+ Route("/", health, methods=["GET", "HEAD", "OPTIONS"]),
1421
+ Route("/health", health, methods=["GET", "HEAD", "OPTIONS"]),
1422
+ Route("/health-mesh", health, methods=["GET", "HEAD", "OPTIONS"]),
1423
+ Route("/api/register", api_register, methods=["POST", "OPTIONS"]),
1424
+ Route("/.well-known/oauth-protected-resource", oauth_discovery, methods=["GET", "HEAD", "OPTIONS"]),
1425
+ Route("/.well-known/oauth-protected-resource/{path:path}", oauth_discovery, methods=["GET", "HEAD", "OPTIONS"]),
1426
+ Route("/sse", mcp_unified_endpoint, methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"]),
1427
+ Route("/mcp", mcp_unified_endpoint, methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"]),
1428
+ Route("/messages", mcp_unified_endpoint, methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"]),
1429
+ WebSocketRoute("/ws/tunnel", ws_tunnel_endpoint),
1430
+ ]
1431
+
1432
+ middleware = [
1433
+ Middleware(
1434
+ CORSMiddleware,
1435
+ allow_origins=["*"],
1436
+ allow_methods=["*"],
1437
+ allow_headers=["*"],
1438
+ expose_headers=["mcp-session-id", "mcp-protocol-version"],
1439
+ )
1440
+ ]
1441
+
1442
+ async def tunnel_watchdog():
1443
+ """Background self-healing loop.
1444
+
1445
+ A websocket can die without a clean close (NAT timeout, host sleep). The entry
1446
+ then stays in active_tunnels, so /health reports the node online while every
1447
+ call hangs until the 28 s timeout. This loop removes such entries, prunes SSE
1448
+ queues nobody consumes and keeps the state truthful.
1449
+ """
1450
+ logger.info("Self-heal watchdog started (30s interval)")
1451
+ while True:
1452
+ try:
1453
+ await asyncio.sleep(30)
1454
+ for user, tunnel in list(active_tunnels.items()):
1455
+ ws = tunnel.get("ws")
1456
+ state = getattr(getattr(ws, "client_state", None), "name", "UNKNOWN")
1457
+ if ws is None or state not in ("CONNECTED",):
1458
+ logger.warning(f"Watchdog: removing stale tunnel for '{user}' (state={state})")
1459
+ for fut in list(tunnel.get("pending", {}).values()):
1460
+ if not fut.done():
1461
+ fut.set_exception(ConnectionResetError("Tunnel was stale"))
1462
+ active_tunnels.pop(user, None)
1463
+ # drop subscriber queues that no stream is reading any more
1464
+ for user, queues in list(active_sse_subscribers.items()):
1465
+ alive = {q for q in queues if not q.empty() or q in active_sse_sessions.values()}
1466
+ if alive != queues:
1467
+ active_sse_subscribers[user] = alive
1468
+ if not alive:
1469
+ active_sse_subscribers.pop(user, None)
1470
+ except asyncio.CancelledError:
1471
+ raise
1472
+ except Exception as exc: # never let the watchdog die
1473
+ logger.warning(f"Watchdog error: {exc}")
1474
+
1475
+
1476
+ @asynccontextmanager
1477
+ async def lifespan(_app):
1478
+ """Start the self-healing watchdog with the app (modern Starlette API)."""
1479
+ task = asyncio.create_task(tunnel_watchdog())
1480
+ try:
1481
+ yield
1482
+ finally:
1483
+ task.cancel()
1484
+
1485
+ app = Starlette(debug=False, routes=routes, middleware=middleware, lifespan=lifespan)
1486
+
1487
+
1488
+
1489
+
1490
+ if __name__ == "__main__":
1491
+ uvicorn.run(app, host="127.0.0.1", port=8096, log_level="info", timeout_graceful_shutdown=2)