gemini-computer-use 0.4.2 → 0.4.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/README.md +18 -1086
- package/cli.js +56 -0
- package/package.json +14 -24
- package/LICENSE +0 -21
- package/bin/cli.js +0 -122
- package/core/__init__.py +0 -0
- package/core/agent.py +0 -767
- package/core/agent_harness.py +0 -67
- package/core/code_cache.py +0 -244
- package/core/device.py +0 -2400
- package/core/domain.py +0 -303
- package/core/hooks.py +0 -61
- package/core/mcp_tools.py +0 -3497
- package/core/policies.py +0 -132
- package/core/schemas.py +0 -49
- package/core/server.py +0 -972
- package/core/subagents.py +0 -55
- package/core/termux.py +0 -916
- package/core/triggers.py +0 -70
- package/core/updater.py +0 -1713
- package/core/version.py +0 -31
- package/core/vitals.py +0 -181
- package/core/web_share.py +0 -814
- package/gateway.py +0 -2862
- package/install-gui.cmd +0 -40
- package/install-gui.ps1 +0 -1882
- package/install-gui.strings.json +0 -211
- package/install.ps1 +0 -1287
- package/install.sh +0 -1998
- package/ops/doctor.ps1 +0 -735
- package/ops/mesh-domain.sh +0 -86
- package/ops/nginx/README.md +0 -77
- package/ops/nginx/antigravity-mesh-mcp.conf +0 -172
- package/ops/nginx/mesh-domain.vhost.template +0 -58
- package/ops/nginx/render-domain.sh +0 -123
- package/ops/update.ps1 +0 -373
- package/ops/windows/README.md +0 -298
- package/ops/windows/agent-watchdog.ps1 +0 -575
- package/ops/windows/run-hidden.vbs +0 -64
- package/skills/orchestrator.md +0 -26
package/gateway.py
DELETED
|
@@ -1,2862 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import base64
|
|
3
|
-
from collections import deque
|
|
4
|
-
from contextlib import asynccontextmanager
|
|
5
|
-
import datetime
|
|
6
|
-
import hmac
|
|
7
|
-
import json
|
|
8
|
-
import logging
|
|
9
|
-
import os
|
|
10
|
-
import re
|
|
11
|
-
import secrets
|
|
12
|
-
import shutil
|
|
13
|
-
import socket
|
|
14
|
-
import time
|
|
15
|
-
import uuid
|
|
16
|
-
from urllib.parse import urlparse
|
|
17
|
-
from starlette.applications import Starlette
|
|
18
|
-
from starlette.middleware import Middleware
|
|
19
|
-
from starlette.middleware.cors import CORSMiddleware
|
|
20
|
-
from starlette.requests import Request
|
|
21
|
-
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
22
|
-
from starlette.routing import Route, WebSocketRoute
|
|
23
|
-
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
24
|
-
import uvicorn
|
|
25
|
-
|
|
26
|
-
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
27
|
-
logger = logging.getLogger("gateway")
|
|
28
|
-
|
|
29
|
-
REGISTRY_PATH = "/etc/antigravity-mesh/registry.json"
|
|
30
|
-
active_tunnels = {} # user -> {"ws": WebSocket, "pending": {req_id: Future}}
|
|
31
|
-
# user -> list of asyncio.Queue, OLDEST stream first. This was a set, which has
|
|
32
|
-
# no order: the per-user cap below has to evict the OLDEST stream, and every
|
|
33
|
-
# other reader (broadcast, /health, the watchdog) only iterates or measures the
|
|
34
|
-
# container, so the type change stays invisible outside this module.
|
|
35
|
-
active_sse_subscribers = {}
|
|
36
|
-
active_sse_sessions = {} # per-connection stream_key -> asyncio.Queue
|
|
37
|
-
# client-visible session id -> queues opened under it. active_sse_sessions is
|
|
38
|
-
# keyed per CONNECTION on purpose (one DELETE must not close a stream another
|
|
39
|
-
# connection is still reading), so this second index is what lets a
|
|
40
|
-
# re-initialize close exactly the streams of the session it replaces.
|
|
41
|
-
active_sse_session_queues = {}
|
|
42
|
-
active_sessions = {} # session_id -> user, issued once per initialize
|
|
43
|
-
latest_session_by_user = {} # user -> most recently issued session id
|
|
44
|
-
|
|
45
|
-
# A client that lost its session id opens a new stream every turn and never
|
|
46
|
-
# closes the old one (observed: 9 abandoned streams for one user while no tunnel
|
|
47
|
-
# was connected). Nothing above this many streams for one user is kept: the
|
|
48
|
-
# oldest is closed, because the newest is the one the client just opened.
|
|
49
|
-
# MESH_MAX_SSE_PER_USER raises the cap for a deployment whose clients legitimately
|
|
50
|
-
# keep several streams open; the default stays deliberately small.
|
|
51
|
-
try:
|
|
52
|
-
MAX_SSE_PER_USER = max(1, int(os.environ.get("MESH_MAX_SSE_PER_USER") or "2"))
|
|
53
|
-
except (TypeError, ValueError):
|
|
54
|
-
MAX_SSE_PER_USER = 2
|
|
55
|
-
|
|
56
|
-
# Tunnel lifecycle history for /health. "node_online: false" alone does not say
|
|
57
|
-
# WHEN the node was last seen, which is the first question after an unexpected
|
|
58
|
-
# reboot; the ring buffer is bounded so a long-lived gateway cannot grow.
|
|
59
|
-
last_tunnel_seen = None # ISO-8601 timestamp of the last successful connection
|
|
60
|
-
TUNNEL_EVENTS_MAX = 10
|
|
61
|
-
tunnel_events = deque(maxlen=TUNNEL_EVENTS_MAX) # newest last
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def record_tunnel_event(event: str, user: str, reason: str = "") -> None:
|
|
65
|
-
"""Record one tunnel connect/disconnect and stamp the last connect time."""
|
|
66
|
-
global last_tunnel_seen
|
|
67
|
-
ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
68
|
-
if event == "connect":
|
|
69
|
-
last_tunnel_seen = ts
|
|
70
|
-
tunnel_events.append({"ts": ts, "event": event, "user": user, "reason": reason})
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def forget_sse_queue(user: str, queue) -> None:
|
|
74
|
-
"""Unregister a queue from the per-user list and from the session index.
|
|
75
|
-
|
|
76
|
-
Shared by the stream's own cleanup and the DELETE teardown so both keep the
|
|
77
|
-
two containers in step; a stream that is still registered after it stopped
|
|
78
|
-
being readable is exactly the leak ``/health`` is meant to expose.
|
|
79
|
-
"""
|
|
80
|
-
subscribers = active_sse_subscribers.get(user)
|
|
81
|
-
if subscribers is not None and queue in subscribers:
|
|
82
|
-
subscribers.remove(queue)
|
|
83
|
-
for session_id, queues in list(active_sse_session_queues.items()):
|
|
84
|
-
if queue in queues:
|
|
85
|
-
queues.remove(queue)
|
|
86
|
-
if not queues:
|
|
87
|
-
active_sse_session_queues.pop(session_id, None)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def close_sse_streams_for_session(session_id: str) -> int:
|
|
91
|
-
"""Close every SSE stream opened under a session id that is now superseded.
|
|
92
|
-
|
|
93
|
-
``None`` is the sentinel the stream loop treats as "close this stream", the
|
|
94
|
-
same one the DELETE teardown and the per-user cap use; the stream's own
|
|
95
|
-
``finally`` does the unregistering, so nothing is removed here. Returns how
|
|
96
|
-
many streams were asked to close.
|
|
97
|
-
"""
|
|
98
|
-
queues = active_sse_session_queues.pop(session_id, [])
|
|
99
|
-
for queue in queues:
|
|
100
|
-
try:
|
|
101
|
-
queue.put_nowait(None)
|
|
102
|
-
except Exception:
|
|
103
|
-
pass
|
|
104
|
-
return len(queues)
|
|
105
|
-
|
|
106
|
-
# Negotiated protocol version per user. The value must be identical in the
|
|
107
|
-
# initialize result AND in the mcp-protocol-version header of every response: a
|
|
108
|
-
# client that asked for 2025-11-25 and reads 2024-11-05 back in the header can
|
|
109
|
-
# reject the answer as belonging to a different protocol revision.
|
|
110
|
-
active_protocol_versions = {}
|
|
111
|
-
DEFAULT_PROTOCOL_VERSION = "2024-11-05"
|
|
112
|
-
|
|
113
|
-
# Legacy SSE transport experiment. The classic MCP SSE transport answers a POST
|
|
114
|
-
# with 202 Accepted and an EMPTY body, and delivers the result only on the stream.
|
|
115
|
-
# Set MESH_LEGACY_SSE=1 to switch a client that holds an open stream onto that
|
|
116
|
-
# contract; leave it unset for the current "answer in the body too" behaviour.
|
|
117
|
-
LEGACY_SSE = os.environ.get("MESH_LEGACY_SSE", "").strip() in ("1", "true", "yes")
|
|
118
|
-
|
|
119
|
-
# Answering a POST in the HTTP body AND on the caller's SSE stream hands a strict
|
|
120
|
-
# streamable-HTTP client the SAME response id twice, and it rejects the session.
|
|
121
|
-
# The body alone is what /sse and /mcp clients read - the `initialize` response,
|
|
122
|
-
# which is answered before any stream exists, is proof that they do. Only the
|
|
123
|
-
# legacy /messages transport needs the stream, and LEGACY_SSE (202, no body)
|
|
124
|
-
# deliberately needs it too.
|
|
125
|
-
# Set MESH_BROADCAST_HTTP_RESPONSES=1 to duplicate on purpose while debugging.
|
|
126
|
-
BROADCAST_HTTP_RESPONSES = os.environ.get(
|
|
127
|
-
"MESH_BROADCAST_HTTP_RESPONSES", "0"
|
|
128
|
-
).strip().lower() in ("1", "true", "yes", "on")
|
|
129
|
-
|
|
130
|
-
# ---------------------------------------------------------------------------
|
|
131
|
-
# ONE SHARED DOMAIN FOR EVERY NODE (canonical URL contract)
|
|
132
|
-
# ---------------------------------------------------------------------------
|
|
133
|
-
# Every node is reached through the same public domain and is selected by the
|
|
134
|
-
# ?user= query parameter. Per-device subdomains are deliberately NOT issued:
|
|
135
|
-
# * each extra hostname needs its own DNS record and its own SAN in the TLS
|
|
136
|
-
# certificate, so a device whose name is missing from the certificate fails
|
|
137
|
-
# the handshake and the client reports an opaque "cannot connect to host";
|
|
138
|
-
# * the node name stops being a security-relevant routing key, which keeps the
|
|
139
|
-
# installer's "name already taken -> node-2" behaviour harmless.
|
|
140
|
-
# Canonical endpoints (see README "Connecting to Google Gemini"):
|
|
141
|
-
# SSE https://<shared-domain>/sse?user=<node>&token=<token>
|
|
142
|
-
# HTTP https://<shared-domain>/mcp?user=<node>&token=<token>
|
|
143
|
-
# Tunnel wss://<shared-domain>/ws/tunnel?user=<node>&token=<token>
|
|
144
|
-
# Override the shared domain with MESH_PUBLIC_URL (or its AGY_PUBLIC_BASE_URL
|
|
145
|
-
# alias), or with the domain file - core/domain.py owns that order and holds the
|
|
146
|
-
# single default, so the domain is configured in exactly one place.
|
|
147
|
-
try:
|
|
148
|
-
from core import domain as _domain
|
|
149
|
-
except Exception: # pragma: no cover - gateway.py installed without core/ beside it
|
|
150
|
-
_domain = None
|
|
151
|
-
|
|
152
|
-
if _domain is not None:
|
|
153
|
-
PUBLIC_BASE_URL = _domain.public_base_url()
|
|
154
|
-
else:
|
|
155
|
-
# gateway.py is sometimes installed on its own (for example
|
|
156
|
-
# /opt/antigravity-mesh/gateway.py with no core/ next to it). Shipping
|
|
157
|
-
# core/domain.py alongside it is part of the deployment; this branch only keeps
|
|
158
|
-
# the service starting when that was forgotten, and it refuses to invent a
|
|
159
|
-
# domain of its own - the project default lives in core/domain.py alone.
|
|
160
|
-
PUBLIC_BASE_URL = (
|
|
161
|
-
os.environ.get("MESH_PUBLIC_URL") or os.environ.get("AGY_PUBLIC_BASE_URL") or ""
|
|
162
|
-
).strip().rstrip("/")
|
|
163
|
-
if not PUBLIC_BASE_URL:
|
|
164
|
-
logger.error(
|
|
165
|
-
"core/domain.py is not importable and neither MESH_PUBLIC_URL nor "
|
|
166
|
-
"AGY_PUBLIC_BASE_URL is set: the public domain is unknown, so URLs handed "
|
|
167
|
-
"to clients will be relative. Install core/domain.py next to gateway.py, or "
|
|
168
|
-
"set the domain in the domain file / environment."
|
|
169
|
-
)
|
|
170
|
-
PUBLIC_HOST = urlparse(PUBLIC_BASE_URL).netloc or PUBLIC_BASE_URL
|
|
171
|
-
|
|
172
|
-
# Legacy per-device subdomain resolution. Kept only so URLs handed out before the
|
|
173
|
-
# shared-domain contract keep working; never advertised, and it logs a warning.
|
|
174
|
-
LEGACY_SUBDOMAIN_ACCESS = os.environ.get("MESH_LEGACY_SUBDOMAIN", "1").strip().lower() in (
|
|
175
|
-
"1", "true", "yes", "on"
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def public_url(path: str, user: str, token: str) -> str:
|
|
180
|
-
"""Canonical public URL for one node on the shared domain."""
|
|
181
|
-
return f"{PUBLIC_BASE_URL}{path}?user={user}&token={token}"
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def issue_session_id(user: str) -> str:
|
|
185
|
-
"""Issue a unique ``Mcp-Session-Id`` for one initialize.
|
|
186
|
-
|
|
187
|
-
The spec requires a session id to be unique per session, and Google's
|
|
188
|
-
frontends open several parallel connections per turn (a POST for the call,
|
|
189
|
-
a GET for the stream, a DELETE to tear the session down, often from
|
|
190
|
-
different egress IPs). Returning the user name as the session id -- as this
|
|
191
|
-
gateway used to -- gave every one of those connections the same identity, so
|
|
192
|
-
a DELETE sent by one of them looked like it had terminated the session that
|
|
193
|
-
another connection's in-flight tool call belonged to, and the client dropped
|
|
194
|
-
the answer it had already received. Clients that never echo the header keep
|
|
195
|
-
working: they still fall back to the user name.
|
|
196
|
-
"""
|
|
197
|
-
session_id = uuid.uuid4().hex
|
|
198
|
-
active_sessions[session_id] = user
|
|
199
|
-
latest_session_by_user[user] = session_id
|
|
200
|
-
return session_id
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def announce_endpoint_for_path(path: str) -> bool:
|
|
204
|
-
"""Whether a GET stream on this path may emit the classic ``event: endpoint``.
|
|
205
|
-
|
|
206
|
-
The classic SSE transport announces the POST target (/messages). Streamable
|
|
207
|
-
HTTP (GET /mcp) must carry JSON-RPC frames only, so the frame is suppressed
|
|
208
|
-
there - a strict client otherwise drops the stream it reads results from.
|
|
209
|
-
"""
|
|
210
|
-
return path != "/mcp"
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def should_broadcast_response(path: str) -> bool:
|
|
214
|
-
"""Whether a POST response is also queued on the caller's SSE stream.
|
|
215
|
-
|
|
216
|
-
Duplicating the answer (body + stream) hands a strict streamable-HTTP client
|
|
217
|
-
the same response id twice; it rejects the session and the model never sees
|
|
218
|
-
the result. Only the legacy transports need the stream.
|
|
219
|
-
"""
|
|
220
|
-
return path == "/messages" or LEGACY_SSE or BROADCAST_HTTP_RESPONSES
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def resolve_session_id(request: Request, user: str) -> str:
|
|
224
|
-
"""The session id this connection must see.
|
|
225
|
-
|
|
226
|
-
Prefer the id the client sends back. Otherwise use the session this user most
|
|
227
|
-
recently opened: a client that holds a session but omits the header must not
|
|
228
|
-
be told a different id, or it believes it holds two sessions and aborts one
|
|
229
|
-
(that mismatch was the cause of the original initialize/DELETE churn). The
|
|
230
|
-
bare user name stays as the last resort for header-less legacy clients.
|
|
231
|
-
"""
|
|
232
|
-
return (
|
|
233
|
-
request.headers.get("mcp-session-id")
|
|
234
|
-
or latest_session_by_user.get(user)
|
|
235
|
-
or user
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def protocol_version_for(user: str) -> str:
|
|
240
|
-
return active_protocol_versions.get(user, DEFAULT_PROTOCOL_VERSION)
|
|
241
|
-
|
|
242
|
-
def broadcast_sse(user: str, message: dict) -> int:
|
|
243
|
-
"""Queue a JSON-RPC frame on every open stream of this user.
|
|
244
|
-
|
|
245
|
-
Returns how many streams received it. The count is logged because it is the
|
|
246
|
-
only way to tell a client that reads results from the stream (Gemini Spark)
|
|
247
|
-
whether the answer actually had a live stream to land on: a response
|
|
248
|
-
broadcast to 0 streams reaches nobody on that channel.
|
|
249
|
-
"""
|
|
250
|
-
data = json.dumps(message, ensure_ascii=False)
|
|
251
|
-
queues = list(active_sse_subscribers.get(user, ()))
|
|
252
|
-
for q in queues:
|
|
253
|
-
try:
|
|
254
|
-
q.put_nowait(data)
|
|
255
|
-
except Exception:
|
|
256
|
-
pass
|
|
257
|
-
label = message.get("method") or f"id={message.get('id')}"
|
|
258
|
-
logger.info(f"broadcast [{label}] for '{user}' to {len(queues)} open stream(s)")
|
|
259
|
-
return len(queues)
|
|
260
|
-
|
|
261
|
-
RESERVED_NAMES = {
|
|
262
|
-
"admin", "administrator", "root", "api", "api-ag", "api-agy",
|
|
263
|
-
"drive", "derp", "mcp-tg", "chamber", "proxy-api", "tg-bot-api",
|
|
264
|
-
"anonymous", "gateway", "mesh", "mail", "vpn", "test", "null",
|
|
265
|
-
"undefined", "localhost", "local", "internal", "public", "ws",
|
|
266
|
-
"sse", "mcp", "messages", "health", "health-mesh", "core", "skills",
|
|
267
|
-
"install", "static", "assets"
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
def load_registry() -> dict:
|
|
271
|
-
if os.path.exists(REGISTRY_PATH):
|
|
272
|
-
try:
|
|
273
|
-
with open(REGISTRY_PATH, "r") as f:
|
|
274
|
-
return json.load(f)
|
|
275
|
-
except Exception as e:
|
|
276
|
-
logger.error(f"Failed to read registry: {e}")
|
|
277
|
-
return {}
|
|
278
|
-
|
|
279
|
-
def save_registry(data: dict):
|
|
280
|
-
os.makedirs(os.path.dirname(REGISTRY_PATH), exist_ok=True)
|
|
281
|
-
with open(REGISTRY_PATH, "w") as f:
|
|
282
|
-
json.dump(data, f, indent=2)
|
|
283
|
-
|
|
284
|
-
def tokens_equal(a: str, b: str) -> bool:
|
|
285
|
-
"""Constant-time token comparison that never raises on non-ASCII input.
|
|
286
|
-
|
|
287
|
-
``hmac.compare_digest`` rejects str arguments containing non-ASCII
|
|
288
|
-
characters (TypeError -> HTTP 500). Comparing UTF-8 bytes with
|
|
289
|
-
``surrogateescape`` keeps the comparison constant-time and total.
|
|
290
|
-
"""
|
|
291
|
-
try:
|
|
292
|
-
return hmac.compare_digest(
|
|
293
|
-
str(a).encode("utf-8", "surrogateescape"),
|
|
294
|
-
str(b).encode("utf-8", "surrogateescape"),
|
|
295
|
-
)
|
|
296
|
-
except Exception:
|
|
297
|
-
return False
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def get_target_user(request: Request) -> str:
|
|
301
|
-
"""Resolve which node a request addresses.
|
|
302
|
-
|
|
303
|
-
Canonical form: ``<shared-domain>/...?user=<node>&token=<token>``. The shared
|
|
304
|
-
domain only selects the gateway, so ``?user=`` is authoritative. A per-device
|
|
305
|
-
subdomain is accepted as a legacy fallback (MESH_LEGACY_SUBDOMAIN=0 disables
|
|
306
|
-
it) and logs a warning telling the caller to switch to the canonical URL.
|
|
307
|
-
"""
|
|
308
|
-
user = (request.query_params.get("user") or "").strip()
|
|
309
|
-
if user:
|
|
310
|
-
return user.lower()
|
|
311
|
-
|
|
312
|
-
if LEGACY_SUBDOMAIN_ACCESS:
|
|
313
|
-
host_part = request.headers.get("host", "").split(":")[0].lower()
|
|
314
|
-
base = PUBLIC_HOST.lower()
|
|
315
|
-
if host_part.endswith(f".{base}") and host_part != base:
|
|
316
|
-
sub = host_part[:-len(base)-1]
|
|
317
|
-
parts = sub.split(".")
|
|
318
|
-
legacy_user = parts[-1].lower()
|
|
319
|
-
logger.warning(
|
|
320
|
-
"Legacy per-device subdomain '%s' used instead of the shared domain; "
|
|
321
|
-
"switch to %s/sse?user=%s&token=...", host_part, PUBLIC_BASE_URL, legacy_user,
|
|
322
|
-
)
|
|
323
|
-
return legacy_user
|
|
324
|
-
|
|
325
|
-
return "anonymous"
|
|
326
|
-
|
|
327
|
-
def get_request_token(request: Request) -> str:
|
|
328
|
-
token = request.query_params.get("token") or request.headers.get("x-mesh-token")
|
|
329
|
-
if not token:
|
|
330
|
-
auth = request.headers.get("authorization", "")
|
|
331
|
-
if auth.startswith("Bearer "):
|
|
332
|
-
token = auth[7:].strip()
|
|
333
|
-
return token or ""
|
|
334
|
-
|
|
335
|
-
def verify_token(request: Request) -> tuple[bool, str, str]:
|
|
336
|
-
user = get_target_user(request)
|
|
337
|
-
if user == "anonymous":
|
|
338
|
-
return False, (
|
|
339
|
-
"Node identity is missing. Use the shared-domain URL form "
|
|
340
|
-
f"{PUBLIC_BASE_URL}/sse?user=<node-name>&token=<your_token>."
|
|
341
|
-
), ""
|
|
342
|
-
registry = load_registry()
|
|
343
|
-
if user not in registry:
|
|
344
|
-
return False, f"User '{user}' is not registered.", user
|
|
345
|
-
expected = registry[user].get("token", "")
|
|
346
|
-
token = get_request_token(request)
|
|
347
|
-
if not token or not tokens_equal(token, expected):
|
|
348
|
-
return False, "Invalid or missing token. Specify ?token=<your_token> in the URL.", user
|
|
349
|
-
return True, "ok", user
|
|
350
|
-
|
|
351
|
-
def get_skill(user: str, host: str) -> str:
|
|
352
|
-
return f"""# 🌐 Antigravity Mesh — Node Orchestrator ({user})
|
|
353
|
-
|
|
354
|
-
Target Node: {user} (shared gateway {PUBLIC_HOST})
|
|
355
|
-
Endpoint: {PUBLIC_BASE_URL}/sse?user={user}&token=<your_token>
|
|
356
|
-
Mode: Secure Cloud Gateway + Reverse Tunnel (one shared domain for every node)
|
|
357
|
-
|
|
358
|
-
MANDATORY GROUNDING & EXECUTION DIRECTIVES:
|
|
359
|
-
1. You are connected directly to the user's execution node '{user}' via Antigravity Mesh MCP tools.
|
|
360
|
-
2. STRICT ZERO-HALLUCINATION POLICY:
|
|
361
|
-
- You DO NOT know local directory structure, file names, or environment state in advance.
|
|
362
|
-
- NEVER invent file paths, guess folder contents, or claim a directory is empty without checking.
|
|
363
|
-
- You MUST call `list_dir` or `bash_exec` (e.g. `pwd`, `ls -la`) to inspect the real filesystem first.
|
|
364
|
-
3. DIRECT EXECUTION:
|
|
365
|
-
- Always execute operations directly via MCP tools (`list_dir`, `read_file`, `bash_exec`, `system_vitals`).
|
|
366
|
-
- Never ask the user to run shell commands manually.
|
|
367
|
-
4. Always verify return codes and stdout/stderr before reporting results.
|
|
368
|
-
"""
|
|
369
|
-
|
|
370
|
-
async def call_remote_tool(user: str, name: str, args: dict) -> dict:
|
|
371
|
-
tunnel = active_tunnels.get(user)
|
|
372
|
-
if not tunnel or not tunnel.get("ws"):
|
|
373
|
-
# The agent reconnects within ~2s of a dropped link (VPN flap, network
|
|
374
|
-
# change, service restart), so give it a moment instead of reporting the
|
|
375
|
-
# node as offline immediately. A missing agent is then a rare answer.
|
|
376
|
-
#
|
|
377
|
-
# A host reboot is NOT a flap, though: the agent stays away for minutes,
|
|
378
|
-
# while the client's own frontend gives a tool call roughly 30 s. Waiting
|
|
379
|
-
# 8 s only made the eventual failure arrive later. 4 x 0.5 s covers a real
|
|
380
|
-
# reconnect and keeps the whole tools/call path within ~5 s.
|
|
381
|
-
for _ in range(4): # up to ~2 seconds
|
|
382
|
-
await asyncio.sleep(0.5)
|
|
383
|
-
tunnel = active_tunnels.get(user)
|
|
384
|
-
if tunnel and tunnel.get("ws"):
|
|
385
|
-
break
|
|
386
|
-
if not tunnel or not tunnel.get("ws"):
|
|
387
|
-
return {
|
|
388
|
-
"exit_code": 1,
|
|
389
|
-
"stdout": "",
|
|
390
|
-
"stderr": (f"[Mesh Gateway] Node '{user}' has no active tunnel: the mesh agent on the "
|
|
391
|
-
f"host is not connected to the gateway. Start/restart the mesh agent there, "
|
|
392
|
-
f"then retry; this is a transport problem, not a missing file or permission.")
|
|
393
|
-
}
|
|
394
|
-
req_id = str(uuid.uuid4())
|
|
395
|
-
fut = asyncio.get_running_loop().create_future()
|
|
396
|
-
tunnel["pending"][req_id] = fut
|
|
397
|
-
payload = {
|
|
398
|
-
"id": req_id,
|
|
399
|
-
"method": "tools/call",
|
|
400
|
-
"params": {"name": name, "arguments": args}
|
|
401
|
-
}
|
|
402
|
-
try:
|
|
403
|
-
await tunnel["ws"].send_text(json.dumps(payload))
|
|
404
|
-
# 28s timeout: Agent runs commands with 25s timeout, so 28s gives 3s margin and returns cleanly
|
|
405
|
-
# BEFORE Google's 30s frontend timeout, completely preventing Gemini Error 1076
|
|
406
|
-
res_msg = await asyncio.wait_for(fut, timeout=28.0)
|
|
407
|
-
return res_msg.get("result", res_msg)
|
|
408
|
-
except asyncio.TimeoutError:
|
|
409
|
-
tunnel["pending"].pop(req_id, None)
|
|
410
|
-
return {"exit_code": 124, "error": f"Command timed out after 25s on node '{user}'"}
|
|
411
|
-
except Exception as e:
|
|
412
|
-
tunnel["pending"].pop(req_id, None)
|
|
413
|
-
# Self-heal: a dropped tunnel (network flap, agent restart) is transient.
|
|
414
|
-
# Wait for the agent to come back and replay the call once instead of
|
|
415
|
-
# failing the client's request.
|
|
416
|
-
logger.warning(f"Tunnel call to '{user}' failed ({e}); waiting for reconnect and retrying once")
|
|
417
|
-
for _ in range(4): # up to ~2 seconds
|
|
418
|
-
await asyncio.sleep(0.5)
|
|
419
|
-
fresh = active_tunnels.get(user)
|
|
420
|
-
if fresh and fresh.get("ws") and fresh.get("ws") is not tunnel.get("ws"):
|
|
421
|
-
try:
|
|
422
|
-
retry_id = str(uuid.uuid4())
|
|
423
|
-
retry_fut = asyncio.get_running_loop().create_future()
|
|
424
|
-
fresh["pending"][retry_id] = retry_fut
|
|
425
|
-
await fresh["ws"].send_text(json.dumps({"id": retry_id, "method": "tools/call",
|
|
426
|
-
"params": {"name": name, "arguments": args}}))
|
|
427
|
-
res_msg = await asyncio.wait_for(retry_fut, timeout=20.0)
|
|
428
|
-
logger.info(f"Retry after reconnect succeeded for '{user}'")
|
|
429
|
-
return res_msg.get("result", res_msg)
|
|
430
|
-
except Exception as retry_exc:
|
|
431
|
-
fresh["pending"].pop(retry_id, None)
|
|
432
|
-
logger.warning(f"Retry after reconnect failed for '{user}': {retry_exc}")
|
|
433
|
-
break
|
|
434
|
-
return {"exit_code": 1, "error": str(e)}
|
|
435
|
-
|
|
436
|
-
def remote_tool_error(res):
|
|
437
|
-
"""Return a human-readable error string if a remote tool response is a failure, else None.
|
|
438
|
-
|
|
439
|
-
Covers three cases:
|
|
440
|
-
* the node returned {"error": "..."} (tool-level failure);
|
|
441
|
-
* the node returned {"ok": false, ...};
|
|
442
|
-
* the gateway itself could not reach the node / the tunnel broke, which yields
|
|
443
|
-
a bare {"exit_code": !=0, "stdout", "stderr"} with no tool payload.
|
|
444
|
-
A legitimate job_output payload always carries a "status" key, so it is never
|
|
445
|
-
mistaken for a transport failure even when the job's exit_code != 0.
|
|
446
|
-
"""
|
|
447
|
-
if not isinstance(res, dict):
|
|
448
|
-
return "invalid response from node"
|
|
449
|
-
if res.get("error"):
|
|
450
|
-
return str(res.get("error"))
|
|
451
|
-
if res.get("ok") is False:
|
|
452
|
-
return str(res.get("message") or res.get("stderr") or "operation failed (ok=false)")
|
|
453
|
-
if res.get("exit_code") not in (None, 0) and set(res.keys()) <= {"exit_code", "stdout", "stderr"}:
|
|
454
|
-
return str(res.get("stderr") or res.get("stdout") or f"remote call failed with exit code {res.get('exit_code')}")
|
|
455
|
-
return None
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
async def health(request: Request):
|
|
459
|
-
if request.method == "OPTIONS":
|
|
460
|
-
return Response(status_code=204, headers={"Allow": "GET, HEAD, OPTIONS", "Access-Control-Allow-Origin": "*"})
|
|
461
|
-
user = get_target_user(request)
|
|
462
|
-
is_online = (user in active_tunnels) if user != "anonymous" else False
|
|
463
|
-
return JSONResponse({
|
|
464
|
-
"status": "healthy",
|
|
465
|
-
"service": "antigravity_mesh_gateway",
|
|
466
|
-
"host": PUBLIC_HOST,
|
|
467
|
-
"target_user": user,
|
|
468
|
-
"node_online": is_online,
|
|
469
|
-
"active_tunnels_count": len(active_tunnels),
|
|
470
|
-
# Diagnostics: an abandoned SSE stream keeps a queue alive, and a client
|
|
471
|
-
# with a connection cap would then be unable to open a new one.
|
|
472
|
-
# `open_sse_streams` is the total, `streams_per_user` the same count
|
|
473
|
-
# broken down per node - both unchanged in meaning, now fed by the
|
|
474
|
-
# insertion-ordered per-user container.
|
|
475
|
-
"open_sse_streams": sum(len(q) for q in active_sse_subscribers.values()),
|
|
476
|
-
"streams_per_user": {u: len(q) for u, q in active_sse_subscribers.items()},
|
|
477
|
-
# Tunnel lifecycle. `node_online: false` does not say when the node was
|
|
478
|
-
# last seen, which is what tells a reboot apart from a network flap;
|
|
479
|
-
# `tunnel_events` keeps the last 10 connect/disconnect events and why.
|
|
480
|
-
"last_tunnel_seen": last_tunnel_seen,
|
|
481
|
-
"tunnel_events": list(tunnel_events),
|
|
482
|
-
}, headers={"Access-Control-Allow-Origin": "*"})
|
|
483
|
-
|
|
484
|
-
async def api_register(request: Request):
|
|
485
|
-
if request.method == "OPTIONS":
|
|
486
|
-
return Response(status_code=204, headers={"Allow": "POST, OPTIONS", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*"})
|
|
487
|
-
try:
|
|
488
|
-
data = await request.json()
|
|
489
|
-
except Exception:
|
|
490
|
-
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
|
|
491
|
-
|
|
492
|
-
raw_user = data.get("username", "").strip()
|
|
493
|
-
clean_user = re.sub(r"[^a-z0-9_-]", "", raw_user.lower())
|
|
494
|
-
if not clean_user or len(clean_user) < 2:
|
|
495
|
-
return JSONResponse({"error": "Invalid username (min 2 alphanumeric chars)"}, status_code=400)
|
|
496
|
-
|
|
497
|
-
if clean_user in RESERVED_NAMES:
|
|
498
|
-
return JSONResponse({
|
|
499
|
-
"error": "reserved_username",
|
|
500
|
-
"message": f"Username '{clean_user}' is reserved for system services."
|
|
501
|
-
}, status_code=400)
|
|
502
|
-
|
|
503
|
-
raw_mac = data.get("mac_address", "").strip()
|
|
504
|
-
clean_mac = ""
|
|
505
|
-
if raw_mac:
|
|
506
|
-
mac_hex = re.sub(r"[^0-9a-fA-F]", "", raw_mac).lower()
|
|
507
|
-
if len(mac_hex) == 12:
|
|
508
|
-
clean_mac = ":".join(mac_hex[i:i+2] for i in range(0, 12, 2))
|
|
509
|
-
else:
|
|
510
|
-
clean_mac = raw_mac.lower()
|
|
511
|
-
|
|
512
|
-
clean_os = str(data.get("os", "")).strip()[:100]
|
|
513
|
-
registry = load_registry()
|
|
514
|
-
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
515
|
-
|
|
516
|
-
# --- DEVICE FINGERPRINTING (MAC & OS) ---
|
|
517
|
-
# 1. Check if a device with this MAC address was already registered
|
|
518
|
-
if clean_mac and clean_mac != "00:00:00:00:00:00":
|
|
519
|
-
for u, entry in registry.items():
|
|
520
|
-
entry_mac = entry.get("mac_address", "").lower()
|
|
521
|
-
if entry_mac == clean_mac:
|
|
522
|
-
logger.info(f"Reusing existing node registration '{u}' for MAC {clean_mac}")
|
|
523
|
-
entry["last_seen"] = now_iso
|
|
524
|
-
if clean_os and not entry.get("os"):
|
|
525
|
-
entry["os"] = clean_os
|
|
526
|
-
save_registry(registry)
|
|
527
|
-
token = entry["token"]
|
|
528
|
-
return JSONResponse({
|
|
529
|
-
"status": "success",
|
|
530
|
-
"username": u,
|
|
531
|
-
"token": token,
|
|
532
|
-
"gateway": PUBLIC_HOST,
|
|
533
|
-
"sse_url": public_url("/sse", u, token),
|
|
534
|
-
"tunnel_url": f"wss://{PUBLIC_HOST}/ws/tunnel?user={u}&token={token}",
|
|
535
|
-
"reused": True,
|
|
536
|
-
# `u` is the loop variable above; `user` does not exist in this
|
|
537
|
-
# branch, so every re-registration of an already-known MAC
|
|
538
|
-
# raised NameError and the gateway answered HTTP 500.
|
|
539
|
-
"instructions": f"Antigravity Mesh node '{u}'. Direct access to the real remote host over a reverse tunnel. "
|
|
540
|
-
f"Do not guess paths or outputs: use list_dir/glob_find/grep_search/read_file/bash_exec/"
|
|
541
|
-
f"system_vitals. Use run_job for slow commands; page output with max_chars+cursor."
|
|
542
|
-
})
|
|
543
|
-
|
|
544
|
-
# 2. If not found by MAC, check if username already exists in registry
|
|
545
|
-
auto_suffix = data.get("auto_suffix", False)
|
|
546
|
-
if clean_user in registry:
|
|
547
|
-
req_token = request.headers.get("authorization", "").replace("Bearer ", "").strip() or data.get("token")
|
|
548
|
-
# If valid token provided, update device info for existing user
|
|
549
|
-
if req_token and tokens_equal(req_token, registry[clean_user]["token"]):
|
|
550
|
-
token = registry[clean_user]["token"]
|
|
551
|
-
if clean_mac:
|
|
552
|
-
registry[clean_user]["mac_address"] = clean_mac
|
|
553
|
-
if clean_os:
|
|
554
|
-
registry[clean_user]["os"] = clean_os
|
|
555
|
-
registry[clean_user]["last_seen"] = now_iso
|
|
556
|
-
save_registry(registry)
|
|
557
|
-
return JSONResponse({
|
|
558
|
-
"status": "success",
|
|
559
|
-
"username": clean_user,
|
|
560
|
-
"token": token,
|
|
561
|
-
"gateway": PUBLIC_HOST,
|
|
562
|
-
"sse_url": public_url("/sse", clean_user, token),
|
|
563
|
-
"tunnel_url": f"wss://{PUBLIC_HOST}/ws/tunnel?user={clean_user}&token={token}",
|
|
564
|
-
"reused": True,
|
|
565
|
-
"instructions": "Authenticated and updated existing user registration."
|
|
566
|
-
})
|
|
567
|
-
elif auto_suffix:
|
|
568
|
-
# Different device claiming same name -> generate unique suffix
|
|
569
|
-
suffix = 2
|
|
570
|
-
cand = f"{clean_user}-{suffix}"
|
|
571
|
-
while cand in registry or cand in RESERVED_NAMES:
|
|
572
|
-
suffix += 1
|
|
573
|
-
cand = f"{clean_user}-{suffix}"
|
|
574
|
-
clean_user = cand
|
|
575
|
-
token = secrets.token_hex(16)
|
|
576
|
-
registry[clean_user] = {
|
|
577
|
-
"token": token,
|
|
578
|
-
"created_at": now_iso,
|
|
579
|
-
"mode": "tunnel",
|
|
580
|
-
"status": "active",
|
|
581
|
-
"mac_address": clean_mac,
|
|
582
|
-
"os": clean_os
|
|
583
|
-
}
|
|
584
|
-
save_registry(registry)
|
|
585
|
-
else:
|
|
586
|
-
suffix = 2
|
|
587
|
-
cand = f"{clean_user}-{suffix}"
|
|
588
|
-
while cand in registry or cand in RESERVED_NAMES:
|
|
589
|
-
suffix += 1
|
|
590
|
-
cand = f"{clean_user}-{suffix}"
|
|
591
|
-
return JSONResponse({
|
|
592
|
-
"error": "conflict",
|
|
593
|
-
"message": f"User '{clean_user}' already exists. Provide token to retrieve config.",
|
|
594
|
-
"suggested_username": cand
|
|
595
|
-
}, status_code=409)
|
|
596
|
-
else:
|
|
597
|
-
# Brand new registration
|
|
598
|
-
token = secrets.token_hex(16)
|
|
599
|
-
registry[clean_user] = {
|
|
600
|
-
"token": token,
|
|
601
|
-
"created_at": now_iso,
|
|
602
|
-
"mode": "tunnel",
|
|
603
|
-
"status": "active",
|
|
604
|
-
"mac_address": clean_mac,
|
|
605
|
-
"os": clean_os
|
|
606
|
-
}
|
|
607
|
-
save_registry(registry)
|
|
608
|
-
|
|
609
|
-
return JSONResponse({
|
|
610
|
-
"status": "success",
|
|
611
|
-
"username": clean_user,
|
|
612
|
-
"token": token,
|
|
613
|
-
"gateway": PUBLIC_HOST,
|
|
614
|
-
"sse_url": public_url("/sse", clean_user, token),
|
|
615
|
-
"tunnel_url": f"wss://{PUBLIC_HOST}/ws/tunnel?user={clean_user}&token={token}",
|
|
616
|
-
"reused": False,
|
|
617
|
-
"instructions": "Add the sse_url to Google Gemini Web (Settings -> MCP)."
|
|
618
|
-
})
|
|
619
|
-
|
|
620
|
-
async def oauth_discovery(request: Request):
|
|
621
|
-
"""RFC 9728 protected-resource metadata.
|
|
622
|
-
|
|
623
|
-
Returning 404 here made every MCP client log a connection error while
|
|
624
|
-
connecting (Gemini Spark probes this path twice). The mesh authenticates with
|
|
625
|
-
a bearer token in the URL, so the honest answer is metadata that advertises
|
|
626
|
-
no authorization server and no bearer requirement for discovery.
|
|
627
|
-
"""
|
|
628
|
-
host = request.headers.get("host", PUBLIC_HOST)
|
|
629
|
-
base = f"https://{host}"
|
|
630
|
-
return JSONResponse(
|
|
631
|
-
{
|
|
632
|
-
"resource": base,
|
|
633
|
-
"authorization_servers": [],
|
|
634
|
-
"bearer_methods_supported": ["query", "header"],
|
|
635
|
-
"scopes_supported": [],
|
|
636
|
-
"resource_documentation": "https://github.com/LevRa7/Computer-use-for-Gemini-App-Web",
|
|
637
|
-
"resource_name": "Antigravity Mesh",
|
|
638
|
-
},
|
|
639
|
-
headers={
|
|
640
|
-
"Access-Control-Allow-Origin": "*",
|
|
641
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, HEAD",
|
|
642
|
-
"Access-Control-Allow-Headers": "*",
|
|
643
|
-
"Cache-Control": "public, max-age=300",
|
|
644
|
-
},
|
|
645
|
-
)
|
|
646
|
-
|
|
647
|
-
async def sse_endpoint(request: Request):
|
|
648
|
-
ok, msg, user = verify_token(request)
|
|
649
|
-
if not ok:
|
|
650
|
-
return JSONResponse(
|
|
651
|
-
{"error": "unauthorized", "message": msg},
|
|
652
|
-
status_code=401,
|
|
653
|
-
headers={"Access-Control-Allow-Origin": "*"}
|
|
654
|
-
)
|
|
655
|
-
|
|
656
|
-
if request.method == "HEAD":
|
|
657
|
-
return Response(
|
|
658
|
-
status_code=200,
|
|
659
|
-
media_type="text/event-stream",
|
|
660
|
-
headers={
|
|
661
|
-
"Access-Control-Allow-Origin": "*",
|
|
662
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
663
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
664
|
-
}
|
|
665
|
-
)
|
|
666
|
-
|
|
667
|
-
token = get_request_token(request)
|
|
668
|
-
# Two different things are needed here, and conflating them broke delivery:
|
|
669
|
-
# * the id the CLIENT sees must match the one POST responses return, or the
|
|
670
|
-
# client believes it holds two sessions and aborts one;
|
|
671
|
-
# * the key this queue is stored under must be UNIQUE per connection,
|
|
672
|
-
# because Google's frontends load-balance requests across many IPs and
|
|
673
|
-
# any one of them may send DELETE. With a shared key that DELETE destroyed
|
|
674
|
-
# the stream another in-flight request was still reading, so its result
|
|
675
|
-
# never reached the model.
|
|
676
|
-
session_id = resolve_session_id(request, user)
|
|
677
|
-
stream_key = str(uuid.uuid4())
|
|
678
|
-
# The classic SSE transport announces where to POST. A Streamable HTTP client
|
|
679
|
-
# (GET /mcp) must receive JSON-RPC frames only: an `endpoint` frame pointing
|
|
680
|
-
# at a different path is not part of that protocol, and a strict client drops
|
|
681
|
-
# the stream over it - the stream it was reading tool results from.
|
|
682
|
-
announce_endpoint = announce_endpoint_for_path(request.url.path)
|
|
683
|
-
queue = asyncio.Queue()
|
|
684
|
-
queues = active_sse_subscribers.setdefault(user, [])
|
|
685
|
-
queues.append(queue)
|
|
686
|
-
# Cap the streams one user may hold. Nothing in the protocol closes a stream
|
|
687
|
-
# a client forgot, so a client whose session id was lost (or that reconnects
|
|
688
|
-
# on every turn) accumulates queues until its own connection limit is reached
|
|
689
|
-
# and it can no longer open one at all. Evicting the OLDEST is safe: the
|
|
690
|
-
# newest is the stream the client just opened and is actually reading.
|
|
691
|
-
evicted = 0
|
|
692
|
-
while len(queues) > MAX_SSE_PER_USER:
|
|
693
|
-
oldest = queues.pop(0)
|
|
694
|
-
evicted += 1
|
|
695
|
-
try:
|
|
696
|
-
oldest.put_nowait(None) # sentinel: the stream loop closes on None
|
|
697
|
-
except Exception:
|
|
698
|
-
pass
|
|
699
|
-
# Also drop it from the session index now, so /health and a later
|
|
700
|
-
# re-initialize never see a stream that was already closed.
|
|
701
|
-
forget_sse_queue(user, oldest)
|
|
702
|
-
# stored under the per-connection key, not the shared client-visible id
|
|
703
|
-
active_sse_sessions[stream_key] = queue
|
|
704
|
-
# ...and indexed by the client-visible session id, so a re-initialize can
|
|
705
|
-
# close exactly the streams of the session it replaces.
|
|
706
|
-
active_sse_session_queues.setdefault(session_id, []).append(queue)
|
|
707
|
-
logger.info(
|
|
708
|
-
f"SSE stream opened for '{user}' (stream_key={stream_key}, "
|
|
709
|
-
f"session={session_id[:12]}…, path={request.url.path}, "
|
|
710
|
-
f"open_now={len(queues)}"
|
|
711
|
-
+ (f", evicted={evicted} over the cap of {MAX_SSE_PER_USER}" if evicted else "")
|
|
712
|
-
+ ")"
|
|
713
|
-
)
|
|
714
|
-
|
|
715
|
-
async def event_generator():
|
|
716
|
-
# Keepalive deadline: intermediaries (nginx, Google's frontend) close an
|
|
717
|
-
# idle stream, and a closed stream is what makes a client believe the
|
|
718
|
-
# server went away. One frame - a comment counts - goes out at least every
|
|
719
|
-
# 15 s. Framing is untouched: `event: endpoint` once, then one
|
|
720
|
-
# `event: message` per queued frame.
|
|
721
|
-
keepalive_interval = 15.0
|
|
722
|
-
last_write = time.monotonic()
|
|
723
|
-
try:
|
|
724
|
-
if announce_endpoint:
|
|
725
|
-
yield f"event: endpoint\ndata: /messages?user={user}&token={token}\n\n"
|
|
726
|
-
while True:
|
|
727
|
-
try:
|
|
728
|
-
remaining = keepalive_interval - (time.monotonic() - last_write)
|
|
729
|
-
msg = await asyncio.wait_for(queue.get(), timeout=max(0.25, remaining))
|
|
730
|
-
except asyncio.TimeoutError:
|
|
731
|
-
try:
|
|
732
|
-
yield ": keepalive\n\n"
|
|
733
|
-
except Exception as exc:
|
|
734
|
-
# The write raised: nobody is reading this stream any
|
|
735
|
-
# more, so drop it instead of keeping the queue alive.
|
|
736
|
-
logger.warning(f"SSE keepalive write failed for '{user}' "
|
|
737
|
-
f"(stream_key={stream_key}): {exc}")
|
|
738
|
-
break
|
|
739
|
-
last_write = time.monotonic()
|
|
740
|
-
continue
|
|
741
|
-
except Exception as exc:
|
|
742
|
-
logger.warning(f"SSE queue unreadable for '{user}' "
|
|
743
|
-
f"(stream_key={stream_key}): {exc}")
|
|
744
|
-
break
|
|
745
|
-
if msg is None:
|
|
746
|
-
break
|
|
747
|
-
try:
|
|
748
|
-
yield f"event: message\ndata: {msg}\n\n"
|
|
749
|
-
except Exception as exc:
|
|
750
|
-
logger.warning(f"SSE write failed for '{user}' "
|
|
751
|
-
f"(stream_key={stream_key}): {exc}")
|
|
752
|
-
break
|
|
753
|
-
last_write = time.monotonic()
|
|
754
|
-
except asyncio.CancelledError:
|
|
755
|
-
logger.info(f"SSE stream cancelled for '{user}' (stream_key={stream_key})")
|
|
756
|
-
raise
|
|
757
|
-
finally:
|
|
758
|
-
active_sse_sessions.pop(stream_key, None)
|
|
759
|
-
forget_sse_queue(user, queue)
|
|
760
|
-
logger.info(f"SSE stream closed for '{user}' (stream_key={stream_key}, "
|
|
761
|
-
f"open_now={len(active_sse_subscribers.get(user, ()))})")
|
|
762
|
-
|
|
763
|
-
return StreamingResponse(
|
|
764
|
-
event_generator(),
|
|
765
|
-
media_type="text/event-stream",
|
|
766
|
-
headers={
|
|
767
|
-
"Cache-Control": "no-cache",
|
|
768
|
-
"Connection": "keep-alive",
|
|
769
|
-
"X-Accel-Buffering": "no",
|
|
770
|
-
"Access-Control-Allow-Origin": "*",
|
|
771
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
772
|
-
"mcp-session-id": session_id,
|
|
773
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
774
|
-
}
|
|
775
|
-
)
|
|
776
|
-
|
|
777
|
-
async def messages_endpoint(request: Request):
|
|
778
|
-
ok, err_msg, user = verify_token(request)
|
|
779
|
-
if not ok:
|
|
780
|
-
return JSONResponse(
|
|
781
|
-
{"error": "unauthorized", "message": err_msg},
|
|
782
|
-
status_code=401,
|
|
783
|
-
headers={
|
|
784
|
-
"Access-Control-Allow-Origin": "*",
|
|
785
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
786
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
787
|
-
}
|
|
788
|
-
)
|
|
789
|
-
|
|
790
|
-
try:
|
|
791
|
-
body = await request.json()
|
|
792
|
-
except Exception:
|
|
793
|
-
body = {}
|
|
794
|
-
|
|
795
|
-
method = body.get("method")
|
|
796
|
-
params = body.get("params", {})
|
|
797
|
-
req_id = body.get("id")
|
|
798
|
-
|
|
799
|
-
if req_id is None and method:
|
|
800
|
-
session_id = resolve_session_id(request, user)
|
|
801
|
-
return Response(
|
|
802
|
-
status_code=204,
|
|
803
|
-
headers={
|
|
804
|
-
"mcp-session-id": session_id,
|
|
805
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
806
|
-
"Access-Control-Allow-Origin": "*",
|
|
807
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
808
|
-
}
|
|
809
|
-
)
|
|
810
|
-
|
|
811
|
-
client_ip = request.client.host if request.client else "unknown"
|
|
812
|
-
call_started = time.monotonic()
|
|
813
|
-
# What the client accepts decides how the answer must be framed: a strict
|
|
814
|
-
# streamable-HTTP client that asks only for text/event-stream rejects a plain
|
|
815
|
-
# application/json body even when the payload is correct.
|
|
816
|
-
accept_hdr = (request.headers.get("accept") or "").lower()
|
|
817
|
-
incoming_session = request.headers.get("mcp-session-id") or ""
|
|
818
|
-
proto_hdr = request.headers.get("mcp-protocol-version") or ""
|
|
819
|
-
tool_note = ""
|
|
820
|
-
if method == "tools/call":
|
|
821
|
-
# Log which tool the client asked for: without it a retry loop in the
|
|
822
|
-
# client is impossible to diagnose from the gateway side.
|
|
823
|
-
try:
|
|
824
|
-
tool_note = ", tool=%s, args=%s" % (
|
|
825
|
-
params.get("name"),
|
|
826
|
-
json.dumps(params.get("arguments", {}), ensure_ascii=False)[:120],
|
|
827
|
-
)
|
|
828
|
-
except Exception:
|
|
829
|
-
tool_note = ", tool=?"
|
|
830
|
-
logger.info(f"Incoming MCP RPC from {client_ip} [{user}]: method={method}, id={req_id}{tool_note}"
|
|
831
|
-
+ (f", accept={accept_hdr[:60]!r}" if accept_hdr else "")
|
|
832
|
-
+ (f", session={incoming_session[:40]!r}" if incoming_session else ", session=<none>")
|
|
833
|
-
+ (f", proto-hdr={proto_hdr[:20]!r}" if proto_hdr else ""))
|
|
834
|
-
|
|
835
|
-
resp = {"jsonrpc": "2.0", "id": req_id}
|
|
836
|
-
issued_session_id = "" # set by initialize; echoed in the response header below
|
|
837
|
-
|
|
838
|
-
if method == "initialize":
|
|
839
|
-
# Protocol version negotiation. The spec says a client MUST disconnect
|
|
840
|
-
# when the server answers with a version it does not support, and we used
|
|
841
|
-
# to hard-code 2024-11-05 whatever the client asked for. Echo the client's
|
|
842
|
-
# version when we support it, otherwise fall back to our baseline.
|
|
843
|
-
client_version = str((params or {}).get("protocolVersion") or "")
|
|
844
|
-
supported = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
845
|
-
if client_version in supported:
|
|
846
|
-
negotiated = client_version
|
|
847
|
-
elif re.fullmatch(r"\d{4}-\d{2}-\d{2}", client_version):
|
|
848
|
-
# The tool surface (initialize / tools list+call / ping) is stable
|
|
849
|
-
# across revisions. Answering an older version than the client asked
|
|
850
|
-
# for makes a compliant client DISCONNECT immediately - that was the
|
|
851
|
-
# real cause of the session churn - so echo any dated version rather
|
|
852
|
-
# than lose the session.
|
|
853
|
-
negotiated = client_version
|
|
854
|
-
else:
|
|
855
|
-
negotiated = "2024-11-05"
|
|
856
|
-
active_protocol_versions[user] = negotiated
|
|
857
|
-
# Session hygiene: this initialize replaces whatever session this user
|
|
858
|
-
# held before, and the client will never use that id again. Leaving its
|
|
859
|
-
# streams open is how one user ended up with 9 live streams and no
|
|
860
|
-
# tunnel - the client lost the session id, re-initialized every turn, and
|
|
861
|
-
# every abandoned stream stayed registered (and counted in /health).
|
|
862
|
-
# The response-header contract is untouched: the NEW id is what this
|
|
863
|
-
# response returns.
|
|
864
|
-
previous_session_id = latest_session_by_user.get(user)
|
|
865
|
-
if previous_session_id:
|
|
866
|
-
closed = close_sse_streams_for_session(previous_session_id)
|
|
867
|
-
active_sessions.pop(previous_session_id, None)
|
|
868
|
-
if closed:
|
|
869
|
-
logger.info(f"initialize: closed {closed} superseded SSE stream(s) of session "
|
|
870
|
-
f"{previous_session_id[:12]}… for user '{user}'")
|
|
871
|
-
session_id = issue_session_id(user)
|
|
872
|
-
issued_session_id = session_id
|
|
873
|
-
logger.info(f"initialize: client requested protocolVersion={client_version!r} "
|
|
874
|
-
f"clientInfo={(params or {}).get('clientInfo')} -> answering {negotiated}; "
|
|
875
|
-
f"issued session {session_id[:12]}… for user '{user}'")
|
|
876
|
-
resp["result"] = {
|
|
877
|
-
"protocolVersion": negotiated,
|
|
878
|
-
"capabilities": {
|
|
879
|
-
"tools": {"listChanged": False},
|
|
880
|
-
"prompts": {"listChanged": False},
|
|
881
|
-
"resources": {"subscribe": False, "listChanged": False}
|
|
882
|
-
},
|
|
883
|
-
"serverInfo": {
|
|
884
|
-
"name": "antigravity_mesh",
|
|
885
|
-
"version": "1.0.0"
|
|
886
|
-
},
|
|
887
|
-
"instructions": (
|
|
888
|
-
(
|
|
889
|
-
(
|
|
890
|
-
f"Antigravity Mesh node '{user}' - direct access to the real host over a reverse tunnel.\n"
|
|
891
|
-
f"GROUNDING (mandatory): NEVER state a path, file, directory or process you have not seen "
|
|
892
|
-
f"in a tool result in THIS conversation. Tool output marked [VERIFIED BY TOOL] is real; "
|
|
893
|
-
f"anything marked [TOOL ERROR] means the path does NOT exist - never repeat it as real. "
|
|
894
|
-
f"If you have not inspected something, say so and call list_dir / glob_find / grep_search first.\n"
|
|
895
|
-
f"USE: list_dir/glob_find/grep_search to locate, read_file to read, write_file/edit_file to "
|
|
896
|
-
f"modify, bash_exec for short commands, system_vitals for resources. Only touch paths you "
|
|
897
|
-
f"have confirmed. For slow work (builds, installs, tests, downloads) use run_job, then "
|
|
898
|
-
f"job_output / job_kill / job_list. Long output is paged: pass cursor=<next_cursor>.\n"
|
|
899
|
-
f"REACHABILITY: every tool result you receive in this conversation was produced ON the "
|
|
900
|
-
f"node, so the node IS online. After any successful tool result, never tell the user the "
|
|
901
|
-
f"host is unreachable or that the agent did not answer. If a call fails, report its exact "
|
|
902
|
-
f"error text instead of guessing that the host is down.\n"
|
|
903
|
-
f"DEVICE: if system_info reports a 'device' block whose class is phone or tablet, prefer "
|
|
904
|
-
f"device_info / device_control for device-level work instead of hand-writing termux-* "
|
|
905
|
-
f"commands through bash_exec, and ask the user before any device_capture or "
|
|
906
|
-
f"device_messages call."
|
|
907
|
-
)
|
|
908
|
-
)
|
|
909
|
-
)
|
|
910
|
-
}
|
|
911
|
-
elif method == "notifications/initialized":
|
|
912
|
-
session_id = resolve_session_id(request, user)
|
|
913
|
-
return Response(
|
|
914
|
-
status_code=204,
|
|
915
|
-
headers={
|
|
916
|
-
"mcp-session-id": session_id,
|
|
917
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
918
|
-
"Access-Control-Allow-Origin": "*",
|
|
919
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
920
|
-
}
|
|
921
|
-
)
|
|
922
|
-
elif method == "ping":
|
|
923
|
-
resp["result"] = {}
|
|
924
|
-
elif method == "tools/list":
|
|
925
|
-
# annotate_tools attaches the confirmation policy (read-only / local
|
|
926
|
-
# write / publish / destructive) declared at the bottom of this file.
|
|
927
|
-
resp["result"] = {
|
|
928
|
-
"tools": annotate_tools([
|
|
929
|
-
{
|
|
930
|
-
"name": "list_dir",
|
|
931
|
-
"description": (
|
|
932
|
-
"List a directory on the remote host (default: workspace)."
|
|
933
|
-
),
|
|
934
|
-
"inputSchema": {
|
|
935
|
-
"type": "object",
|
|
936
|
-
"properties": {
|
|
937
|
-
"path": {
|
|
938
|
-
"type": "string",
|
|
939
|
-
"description": "Optional directory path to inspect. If omitted or empty, lists the agent's current working directory."
|
|
940
|
-
}
|
|
941
|
-
},
|
|
942
|
-
"required": []
|
|
943
|
-
}
|
|
944
|
-
},
|
|
945
|
-
{
|
|
946
|
-
"name": "read_file",
|
|
947
|
-
"description": (
|
|
948
|
-
"Read a file, line-numbered. Page with max_chars/cursor."
|
|
949
|
-
),
|
|
950
|
-
"inputSchema": {
|
|
951
|
-
"type": "object",
|
|
952
|
-
"properties": {
|
|
953
|
-
"path": {"type": "string", "description": "File path to read"},
|
|
954
|
-
"start_line": {"type": "integer", "description": "First line (1-based)"},
|
|
955
|
-
"end_line": {"type": "integer", "description": "Last line (1-based)"}
|
|
956
|
-
},
|
|
957
|
-
"required": ["path"]
|
|
958
|
-
}
|
|
959
|
-
},
|
|
960
|
-
{
|
|
961
|
-
"name": "bash_exec",
|
|
962
|
-
"description": (
|
|
963
|
-
f"Execute a shell command directly on {user}'s host machine. "
|
|
964
|
-
"The shell follows the host: bash on Linux/macOS, PowerShell or cmd.exe "
|
|
965
|
-
"on Windows - call system_info first and match command_shell. "
|
|
966
|
-
"Use it to inspect git status, run builds, check processes or run tasks, "
|
|
967
|
-
"and NEVER guess command outputs. Commands that install or remove "
|
|
968
|
-
"software, or that delete data, are refused here: this tool runs without "
|
|
969
|
-
"a confirmation dialog, so those must go through system_change, where the "
|
|
970
|
-
"user is asked to confirm first."
|
|
971
|
-
),
|
|
972
|
-
"inputSchema": {
|
|
973
|
-
"type": "object",
|
|
974
|
-
"properties": {
|
|
975
|
-
"command": {
|
|
976
|
-
"type": "string",
|
|
977
|
-
"description": "Shell command to execute on the node"
|
|
978
|
-
}
|
|
979
|
-
},
|
|
980
|
-
"required": ["command"]
|
|
981
|
-
}
|
|
982
|
-
},
|
|
983
|
-
{
|
|
984
|
-
"name": "system_change",
|
|
985
|
-
"description": (
|
|
986
|
-
"Install or remove software, or delete data, on the host - the only tool "
|
|
987
|
-
"allowed to run those commands, and the one the user is asked to confirm "
|
|
988
|
-
"before it runs. Same shell as bash_exec (check command_shell from "
|
|
989
|
-
"system_info first). Use it for package managers (apt/dnf/pacman/apk/brew/"
|
|
990
|
-
"pip/npm/winget/msiexec/...), uninstallers, rm / Remove-Item / del, disk "
|
|
991
|
-
"wipes and anything else that installs, removes or destroys. Set "
|
|
992
|
-
"background=true for long installs and poll with job_output. Ordinary "
|
|
993
|
-
"inspection, builds and tests belong in bash_exec / run_job instead."
|
|
994
|
-
),
|
|
995
|
-
"inputSchema": {
|
|
996
|
-
"type": "object",
|
|
997
|
-
"properties": {
|
|
998
|
-
"command": {"type": "string",
|
|
999
|
-
"description": "The install / remove / delete command"},
|
|
1000
|
-
"timeout_sec": {"type": "integer",
|
|
1001
|
-
"description": "Foreground only: 1-120 seconds (default 25)"},
|
|
1002
|
-
"background": {"type": "boolean",
|
|
1003
|
-
"description": "Run it as a background job and return a job_id"},
|
|
1004
|
-
"cwd": {"type": "string",
|
|
1005
|
-
"description": "Working directory; background=true only"},
|
|
1006
|
-
"max_chars": {"type": "integer",
|
|
1007
|
-
"description": "Maximum characters per response"},
|
|
1008
|
-
"cursor": {"type": "integer",
|
|
1009
|
-
"description": "Character offset to continue from"}
|
|
1010
|
-
},
|
|
1011
|
-
"required": ["command"]
|
|
1012
|
-
}
|
|
1013
|
-
},
|
|
1014
|
-
{
|
|
1015
|
-
"name": "mesh_status",
|
|
1016
|
-
"description": ("Confirm this node is reachable. Call this FIRST if you believe the "
|
|
1017
|
-
"host is offline: it returns live evidence from the host."),
|
|
1018
|
-
"inputSchema": {"type": "object", "properties": {}, "required": []}
|
|
1019
|
-
},
|
|
1020
|
-
{
|
|
1021
|
-
"name": "mesh_update",
|
|
1022
|
-
"description": ("Report, check for, or install a newer release of the node's own "
|
|
1023
|
-
"code. action=status reads local state; action=check asks GitHub; "
|
|
1024
|
-
"action=apply installs it and restarts the agent (the tunnel "
|
|
1025
|
-
"drops for a few seconds). Default action is status."),
|
|
1026
|
-
"inputSchema": {
|
|
1027
|
-
"type": "object",
|
|
1028
|
-
"properties": {
|
|
1029
|
-
"action": {"type": "string", "enum": ["status", "check", "apply"],
|
|
1030
|
-
"description": "What to do (default: status)"},
|
|
1031
|
-
"force": {"type": "boolean",
|
|
1032
|
-
"description": "Ignore the check interval and the failed-apply backoff"},
|
|
1033
|
-
"offline": {"type": "boolean",
|
|
1034
|
-
"description": "check: cached answer only, no network"},
|
|
1035
|
-
"restart": {"type": "boolean",
|
|
1036
|
-
"description": "apply: restart the node afterwards (default true)"}
|
|
1037
|
-
},
|
|
1038
|
-
"required": []
|
|
1039
|
-
}
|
|
1040
|
-
},
|
|
1041
|
-
# The node's own device (a phone/tablet under Termux, or the local
|
|
1042
|
-
# laptop/desktop). core/mcp_tools.TOOLS is the SOURCE OF TRUTH for
|
|
1043
|
-
# these four entries: their descriptions and schemas below are copied
|
|
1044
|
-
# from it value for value (only the layout is the gateway's own - it
|
|
1045
|
-
# deploys as one file and may not import node code). The node is what
|
|
1046
|
-
# actually executes, and the client reads this copy, so any wording
|
|
1047
|
-
# difference here would be a lie the model acts on. Keep them in step;
|
|
1048
|
-
# tests/test_gateway_share_route.py pins the copy for these four.
|
|
1049
|
-
{
|
|
1050
|
-
"name": "device_info",
|
|
1051
|
-
"description": (
|
|
1052
|
-
"One-call report for the device this node runs on: battery and charging, "
|
|
1053
|
-
"network interfaces with Wi-Fi and cellular signal strength, language, time and "
|
|
1054
|
-
"timezone, CPU, RAM, storage, cameras, microphones, sensors and what the platform "
|
|
1055
|
-
"can actually reach. On an Android/Termux node it reads the phone through the "
|
|
1056
|
-
"termux-api commands; on a laptop or desktop it reads the local system. Every "
|
|
1057
|
-
"section carries available/source, so 'not exposed here' is never reported as a "
|
|
1058
|
-
"zero - and on a non-phone the phone-only sections explain themselves instead of "
|
|
1059
|
-
"failing. Use device_control, device_capture or device_messages to act."
|
|
1060
|
-
),
|
|
1061
|
-
"inputSchema": {
|
|
1062
|
-
"type": "object",
|
|
1063
|
-
"properties": {
|
|
1064
|
-
"section": {
|
|
1065
|
-
"type": "string",
|
|
1066
|
-
"enum": ["summary", "all", "device", "battery", "network", "locale",
|
|
1067
|
-
"time", "hardware", "storage", "cameras", "microphones",
|
|
1068
|
-
"sensors", "capabilities"],
|
|
1069
|
-
"description": "Which blocks to return (default: all)."
|
|
1070
|
-
},
|
|
1071
|
-
"sensor": {
|
|
1072
|
-
"type": "string",
|
|
1073
|
-
"description": ("Name one sensor to take a single live sample from "
|
|
1074
|
-
"(Android only; needs a name from the sensors block).")
|
|
1075
|
-
},
|
|
1076
|
-
"fresh": {
|
|
1077
|
-
"type": "boolean",
|
|
1078
|
-
"description": "Bypass the short-lived cache (default false)."
|
|
1079
|
-
},
|
|
1080
|
-
"quick": {
|
|
1081
|
-
"type": "boolean",
|
|
1082
|
-
"description": ("Skip the slow sections: network, cameras, microphones, "
|
|
1083
|
-
"sensors (default false).")
|
|
1084
|
-
}
|
|
1085
|
-
},
|
|
1086
|
-
"additionalProperties": False
|
|
1087
|
-
}
|
|
1088
|
-
},
|
|
1089
|
-
{
|
|
1090
|
-
"name": "device_control",
|
|
1091
|
-
"description": (
|
|
1092
|
-
"Reversible actions on the node's own device: torch, vibrate, volume, screen "
|
|
1093
|
-
"brightness, text-to-speech, toast, notifications, clipboard, media playback, "
|
|
1094
|
-
"media scan, wake lock, download, opening or sharing a file, a text dialog and "
|
|
1095
|
-
"the wallpaper. Each action is a fixed termux-api command on Android and an "
|
|
1096
|
-
"explained no-op elsewhere; the result names the command and its argv so nothing "
|
|
1097
|
-
"is guessed. Nothing here is destructive, and all of it is refused when the node "
|
|
1098
|
-
"runs with MESH_READ_ONLY=1."
|
|
1099
|
-
),
|
|
1100
|
-
"inputSchema": {
|
|
1101
|
-
"type": "object",
|
|
1102
|
-
"properties": {
|
|
1103
|
-
"action": {
|
|
1104
|
-
"type": "string",
|
|
1105
|
-
"enum": ["torch", "vibrate", "volume", "volume_get", "brightness",
|
|
1106
|
-
"tts_speak", "toast", "notify", "notify_list",
|
|
1107
|
-
"notify_remove", "clipboard_get", "clipboard_set", "media",
|
|
1108
|
-
"media_scan", "wakelock", "download", "open", "share",
|
|
1109
|
-
"dialog", "wallpaper"],
|
|
1110
|
-
"description": "Action to perform."
|
|
1111
|
-
},
|
|
1112
|
-
"on": {
|
|
1113
|
-
"type": "boolean",
|
|
1114
|
-
"description": "torch: true lights it, false puts it out."
|
|
1115
|
-
},
|
|
1116
|
-
"value": {
|
|
1117
|
-
"type": "integer",
|
|
1118
|
-
"description": ("vibrate: milliseconds; volume: level 0-15; "
|
|
1119
|
-
"brightness: 0-255.")
|
|
1120
|
-
},
|
|
1121
|
-
"stream": {
|
|
1122
|
-
"type": "string",
|
|
1123
|
-
"enum": ["music", "call", "alarm", "notification", "ring", "system"],
|
|
1124
|
-
"description": "volume: which audio stream (default music)."
|
|
1125
|
-
},
|
|
1126
|
-
"text": {
|
|
1127
|
-
"type": "string",
|
|
1128
|
-
"description": ("tts_speak/toast/notify/clipboard_set/dialog body; media: "
|
|
1129
|
-
"play|pause|stop|info.")
|
|
1130
|
-
},
|
|
1131
|
-
"title": {
|
|
1132
|
-
"type": "string",
|
|
1133
|
-
"description": "notify/dialog: heading text."
|
|
1134
|
-
},
|
|
1135
|
-
"id": {
|
|
1136
|
-
"type": "string",
|
|
1137
|
-
"description": "notify_remove: the id the notification was posted with."
|
|
1138
|
-
},
|
|
1139
|
-
"url": {
|
|
1140
|
-
"type": "string",
|
|
1141
|
-
"description": "download/open/wallpaper: URL to use."
|
|
1142
|
-
},
|
|
1143
|
-
"path": {
|
|
1144
|
-
"type": "string",
|
|
1145
|
-
"description": "open/share/media/download/media_scan/wallpaper: file on the node."
|
|
1146
|
-
},
|
|
1147
|
-
"state": {
|
|
1148
|
-
"type": "string",
|
|
1149
|
-
"enum": ["acquire", "release", "status"],
|
|
1150
|
-
"description": "wakelock: what to do (default status)."
|
|
1151
|
-
},
|
|
1152
|
-
"timeout_sec": {
|
|
1153
|
-
"type": "integer",
|
|
1154
|
-
"description": "dialog: seconds to wait for the person (5-120, default 30)."
|
|
1155
|
-
}
|
|
1156
|
-
},
|
|
1157
|
-
"required": ["action"],
|
|
1158
|
-
"additionalProperties": False
|
|
1159
|
-
}
|
|
1160
|
-
},
|
|
1161
|
-
{
|
|
1162
|
-
"name": "device_capture",
|
|
1163
|
-
"description": (
|
|
1164
|
-
"Camera, microphone, location, fingerprint, USB and infrared actions. These "
|
|
1165
|
-
"observe the physical world or authenticate the person holding the phone, so the "
|
|
1166
|
-
"client always asks for confirmation first. A photo or a recording is written to "
|
|
1167
|
-
"the node's capture directory and is never uploaded anywhere by itself; "
|
|
1168
|
-
"fingerprint returns only the verdict, never biometric data; USB access requests "
|
|
1169
|
-
"permission and returns the descriptor but deliberately refuses to run a program "
|
|
1170
|
-
"for you; and on Android 11+ the Termux:API app has to be in the foreground for a "
|
|
1171
|
-
"recording. Refused when MESH_READ_ONLY=1."
|
|
1172
|
-
),
|
|
1173
|
-
"inputSchema": {
|
|
1174
|
-
"type": "object",
|
|
1175
|
-
"properties": {
|
|
1176
|
-
"action": {
|
|
1177
|
-
"type": "string",
|
|
1178
|
-
"enum": ["camera_list", "camera_photo", "mic_record_start",
|
|
1179
|
-
"mic_record_stop", "mic_record_status", "location",
|
|
1180
|
-
"fingerprint", "usb_list", "usb_access",
|
|
1181
|
-
"infrared_frequencies", "infrared_transmit"],
|
|
1182
|
-
"description": "Capture action to perform."
|
|
1183
|
-
},
|
|
1184
|
-
"camera_id": {
|
|
1185
|
-
"type": "integer",
|
|
1186
|
-
"description": "camera_photo: id from camera_list (default 0)."
|
|
1187
|
-
},
|
|
1188
|
-
"path": {
|
|
1189
|
-
"type": "string",
|
|
1190
|
-
"description": ("camera_photo/mic_record_start: output file (default: the "
|
|
1191
|
-
"node's capture directory); usb_access: device path from "
|
|
1192
|
-
"usb_list.")
|
|
1193
|
-
},
|
|
1194
|
-
"seconds": {
|
|
1195
|
-
"type": "integer",
|
|
1196
|
-
"description": "mic_record_start: recording length limit in seconds."
|
|
1197
|
-
},
|
|
1198
|
-
"provider": {
|
|
1199
|
-
"type": "string",
|
|
1200
|
-
"enum": ["gps", "network", "passive"],
|
|
1201
|
-
"description": "location: which Android provider to use (default gps)."
|
|
1202
|
-
},
|
|
1203
|
-
"frequency": {
|
|
1204
|
-
"type": "integer",
|
|
1205
|
-
"description": "infrared_transmit: carrier frequency in Hz."
|
|
1206
|
-
},
|
|
1207
|
-
"pattern": {
|
|
1208
|
-
"type": "string",
|
|
1209
|
-
"description": ("infrared_transmit: durations in microseconds, e.g. "
|
|
1210
|
-
"1000,2000,1000.")
|
|
1211
|
-
}
|
|
1212
|
-
},
|
|
1213
|
-
"required": ["action"],
|
|
1214
|
-
"additionalProperties": False
|
|
1215
|
-
}
|
|
1216
|
-
},
|
|
1217
|
-
{
|
|
1218
|
-
"name": "device_messages",
|
|
1219
|
-
"description": (
|
|
1220
|
-
"SMS, call log, contacts and placing a call. This is private data about the person "
|
|
1221
|
-
"who owns the phone, so the client asks for confirmation AND the node refuses "
|
|
1222
|
-
"until its operator enables it with MESH_DEVICE_PIM=1. The refusal names the flag "
|
|
1223
|
-
"and the Android permissions to grant, so the answer is an instruction rather than "
|
|
1224
|
-
"a dead end. Sending an SMS and placing a call are also refused under "
|
|
1225
|
-
"MESH_READ_ONLY=1."
|
|
1226
|
-
),
|
|
1227
|
-
"inputSchema": {
|
|
1228
|
-
"type": "object",
|
|
1229
|
-
"properties": {
|
|
1230
|
-
"action": {
|
|
1231
|
-
"type": "string",
|
|
1232
|
-
"enum": ["sms_list", "sms_send", "call_log", "contacts", "call"],
|
|
1233
|
-
"description": "Message or PIM action to perform."
|
|
1234
|
-
},
|
|
1235
|
-
"number": {
|
|
1236
|
-
"type": "string",
|
|
1237
|
-
"description": "sms_send/call: recipient number."
|
|
1238
|
-
},
|
|
1239
|
-
"text": {
|
|
1240
|
-
"type": "string",
|
|
1241
|
-
"description": "sms_send: message body."
|
|
1242
|
-
},
|
|
1243
|
-
"limit": {
|
|
1244
|
-
"type": "integer",
|
|
1245
|
-
"description": "How many records (1-50, default 10)."
|
|
1246
|
-
},
|
|
1247
|
-
"offset": {
|
|
1248
|
-
"type": "integer",
|
|
1249
|
-
"description": "Record offset, for paging."
|
|
1250
|
-
},
|
|
1251
|
-
"type": {
|
|
1252
|
-
"type": "string",
|
|
1253
|
-
"description": ("sms_list/call_log: filter, e.g. inbox/sent or "
|
|
1254
|
-
"incoming/outgoing/missed.")
|
|
1255
|
-
},
|
|
1256
|
-
"query": {
|
|
1257
|
-
"type": "string",
|
|
1258
|
-
"description": "contacts: filter by name or number."
|
|
1259
|
-
}
|
|
1260
|
-
},
|
|
1261
|
-
"required": ["action"],
|
|
1262
|
-
"additionalProperties": False
|
|
1263
|
-
}
|
|
1264
|
-
},
|
|
1265
|
-
{
|
|
1266
|
-
"name": "system_info",
|
|
1267
|
-
"description": ("One-call host summary: OS, desktop, user, home, disks, memory, load, "
|
|
1268
|
-
"top processes and the current wallpaper. Prefer this over several "
|
|
1269
|
-
"exploratory commands - the client allows only a few calls per turn."),
|
|
1270
|
-
"inputSchema": {"type": "object", "properties": {}, "required": []}
|
|
1271
|
-
},
|
|
1272
|
-
{
|
|
1273
|
-
"name": "system_vitals",
|
|
1274
|
-
"description": f"Retrieve real-time CPU, RAM, and Disk metrics on {user}'s host machine",
|
|
1275
|
-
"inputSchema": {"type": "object", "properties": {}, "required": []}
|
|
1276
|
-
},
|
|
1277
|
-
{
|
|
1278
|
-
"name": "get_orchestration_skill",
|
|
1279
|
-
"description": "Load the node orchestration rules.",
|
|
1280
|
-
"inputSchema": {"type": "object", "properties": {}, "required": []}
|
|
1281
|
-
},
|
|
1282
|
-
{
|
|
1283
|
-
"name": "write_file",
|
|
1284
|
-
"description": (
|
|
1285
|
-
"Write a file atomically (creates parent dirs). Runs without a "
|
|
1286
|
-
"confirmation dialog, so a path inside the system - /etc, /usr, "
|
|
1287
|
-
"/boot, /var/lib, C:\\Windows, C:\\Program Files, ProgramData, the "
|
|
1288
|
-
"registry - is refused and has to go through system_write, which "
|
|
1289
|
-
"the user is asked to confirm."
|
|
1290
|
-
),
|
|
1291
|
-
"inputSchema": {
|
|
1292
|
-
"type": "object",
|
|
1293
|
-
"properties": {
|
|
1294
|
-
"path": {"type": "string", "description": "Absolute path, or relative to the agent's workspace"},
|
|
1295
|
-
"content": {"type": "string", "description": "Exact file content to write"},
|
|
1296
|
-
"create_dirs": {"type": "boolean", "description": "Create parent directories"},
|
|
1297
|
-
"mode": {"type": "string", "description": "File mode, e.g. 0644"}
|
|
1298
|
-
},
|
|
1299
|
-
"required": ["path", "content"]
|
|
1300
|
-
}
|
|
1301
|
-
},
|
|
1302
|
-
{
|
|
1303
|
-
"name": "edit_file",
|
|
1304
|
-
"description": (
|
|
1305
|
-
"Replace an exact string in a file; old_string must be unique. Runs "
|
|
1306
|
-
"without a confirmation dialog, so a system path is refused here - "
|
|
1307
|
-
"read the file and write the whole new content with system_write "
|
|
1308
|
-
"instead, which the user confirms."
|
|
1309
|
-
),
|
|
1310
|
-
"inputSchema": {
|
|
1311
|
-
"type": "object",
|
|
1312
|
-
"properties": {
|
|
1313
|
-
"path": {"type": "string", "description": "File to edit"},
|
|
1314
|
-
"old_string": {"type": "string", "description": "Exact text to replace (not a regex); must be unique unless replace_all is true"},
|
|
1315
|
-
"new_string": {"type": "string", "description": "Replacement text"},
|
|
1316
|
-
"expected_sha256": {"type": "string", "description": "Fail if file changed since read"},
|
|
1317
|
-
"replace_all": {"type": "boolean", "description": "Replace every occurrence"}
|
|
1318
|
-
},
|
|
1319
|
-
"required": ["path", "old_string", "new_string"]
|
|
1320
|
-
}
|
|
1321
|
-
},
|
|
1322
|
-
{
|
|
1323
|
-
"name": "system_write",
|
|
1324
|
-
"description": (
|
|
1325
|
-
"Write a file inside a system path - /etc, /usr, /boot, /var/lib, "
|
|
1326
|
-
"C:\\Windows, C:\\Program Files, ProgramData, the registry - the only "
|
|
1327
|
-
"way to write there, and the one the user is asked to confirm before "
|
|
1328
|
-
"it runs. Takes the exact content, like write_file (no shell "
|
|
1329
|
-
"quoting). To change an existing system file, read_file it first and "
|
|
1330
|
-
"send the full new content here."
|
|
1331
|
-
),
|
|
1332
|
-
"inputSchema": {
|
|
1333
|
-
"type": "object",
|
|
1334
|
-
"properties": {
|
|
1335
|
-
"path": {"type": "string", "description": "System path to write"},
|
|
1336
|
-
"content": {"type": "string", "description": "Exact file content to write"},
|
|
1337
|
-
"create_dirs": {"type": "boolean", "description": "Create parent directories"},
|
|
1338
|
-
"mode": {"type": "string", "description": "File mode, e.g. 0644 (POSIX only)"}
|
|
1339
|
-
},
|
|
1340
|
-
"required": ["path", "content"]
|
|
1341
|
-
}
|
|
1342
|
-
},
|
|
1343
|
-
{
|
|
1344
|
-
"name": "grep_search",
|
|
1345
|
-
"description": (
|
|
1346
|
-
"Recursively search file contents; returns path:line: text."
|
|
1347
|
-
),
|
|
1348
|
-
"inputSchema": {
|
|
1349
|
-
"type": "object",
|
|
1350
|
-
"properties": {
|
|
1351
|
-
"pattern": {"type": "string", "description": "Text or regular expression to search for"},
|
|
1352
|
-
"path": {"type": "string", "description": "Directory to search recursively (default \".\")"},
|
|
1353
|
-
"glob": {"type": "string", "description": "Filename filter"},
|
|
1354
|
-
"limit": {"type": "integer", "description": "Max results"},
|
|
1355
|
-
"ignore_case": {"type": "boolean", "description": "Case-insensitive"},
|
|
1356
|
-
"fixed": {"type": "boolean", "description": "Literal text, not regex"},
|
|
1357
|
-
"context": {"type": "integer", "description": "Context lines"}
|
|
1358
|
-
},
|
|
1359
|
-
"required": ["pattern"]
|
|
1360
|
-
}
|
|
1361
|
-
},
|
|
1362
|
-
{
|
|
1363
|
-
"name": "glob_find",
|
|
1364
|
-
"description": (
|
|
1365
|
-
"Find files by glob pattern (* and **)."
|
|
1366
|
-
),
|
|
1367
|
-
"inputSchema": {
|
|
1368
|
-
"type": "object",
|
|
1369
|
-
"properties": {
|
|
1370
|
-
"pattern": {"type": "string", "description": "Glob pattern, e.g. \"**/*.py\" or \"src/*.json\""},
|
|
1371
|
-
"path": {"type": "string", "description": "Base directory to search from (default \".\")"}
|
|
1372
|
-
},
|
|
1373
|
-
"required": ["pattern"]
|
|
1374
|
-
}
|
|
1375
|
-
},
|
|
1376
|
-
{
|
|
1377
|
-
"name": "run_job",
|
|
1378
|
-
"description": (
|
|
1379
|
-
"Start a long command in the background; returns job_id. Installs, "
|
|
1380
|
-
"removals and deletions are refused here (this tool has no confirmation "
|
|
1381
|
-
"step) - run those with system_change(background=true)."
|
|
1382
|
-
),
|
|
1383
|
-
"inputSchema": {
|
|
1384
|
-
"type": "object",
|
|
1385
|
-
"properties": {
|
|
1386
|
-
"command": {"type": "string", "description": "Shell command to run in the background"},
|
|
1387
|
-
"cwd": {"type": "string", "description": "Optional working directory"}
|
|
1388
|
-
},
|
|
1389
|
-
"required": ["command"]
|
|
1390
|
-
}
|
|
1391
|
-
},
|
|
1392
|
-
{
|
|
1393
|
-
"name": "job_output",
|
|
1394
|
-
"description": (
|
|
1395
|
-
"Get a job status/output. wait_ms<=20000; page via next_cursor."
|
|
1396
|
-
),
|
|
1397
|
-
"inputSchema": {
|
|
1398
|
-
"type": "object",
|
|
1399
|
-
"properties": {
|
|
1400
|
-
"job_id": {"type": "string", "description": "Job id returned by run_job"},
|
|
1401
|
-
"wait_ms": {"type": "integer", "description": "Block up to this long (max 20000)"},
|
|
1402
|
-
"max_chars": {"type": "integer", "description": "Chunk size"},
|
|
1403
|
-
"cursor": {"type": "integer", "description": "Offset to continue from"}
|
|
1404
|
-
},
|
|
1405
|
-
"required": ["job_id"]
|
|
1406
|
-
}
|
|
1407
|
-
},
|
|
1408
|
-
{
|
|
1409
|
-
"name": "job_kill",
|
|
1410
|
-
"description": "Stop a running job.",
|
|
1411
|
-
"inputSchema": {
|
|
1412
|
-
"type": "object",
|
|
1413
|
-
"properties": {
|
|
1414
|
-
"job_id": {"type": "string", "description": "Job id to stop"},
|
|
1415
|
-
"signal": {"type": "string", "description": "Signal to send, e.g. TERM or KILL (default TERM)"}
|
|
1416
|
-
},
|
|
1417
|
-
"required": ["job_id"]
|
|
1418
|
-
}
|
|
1419
|
-
},
|
|
1420
|
-
{
|
|
1421
|
-
"name": "job_list",
|
|
1422
|
-
"description": "List recent jobs, newest first (20 by default).",
|
|
1423
|
-
"inputSchema": {"type": "object", "properties": {
|
|
1424
|
-
"limit": {"type": "integer", "description": "How many jobs to return (1-50, default 20)"}
|
|
1425
|
-
}, "required": []}
|
|
1426
|
-
}
|
|
1427
|
-
] + SHARE_TOOL_SPECS)
|
|
1428
|
-
}
|
|
1429
|
-
elif method == "tools/call":
|
|
1430
|
-
name = params.get("name")
|
|
1431
|
-
args = params.get("arguments", {})
|
|
1432
|
-
is_error = False
|
|
1433
|
-
if name == "get_orchestration_skill":
|
|
1434
|
-
content_text = get_skill(user, request.headers.get("host", PUBLIC_HOST))
|
|
1435
|
-
elif name == "mesh_status":
|
|
1436
|
-
res = await call_remote_tool(user, name, args)
|
|
1437
|
-
err = remote_tool_error(res)
|
|
1438
|
-
if err:
|
|
1439
|
-
is_error = True
|
|
1440
|
-
content_text = f"[Error] {err}"
|
|
1441
|
-
else:
|
|
1442
|
-
parts = ["[NODE REACHABLE] This answer was produced on the host itself - the mesh "
|
|
1443
|
-
"agent and the tunnel are working. Do not claim the node is offline."]
|
|
1444
|
-
for key in ("hostname", "agent_pid", "checked_at", "host_uptime_human",
|
|
1445
|
-
"workspace", "node_version"):
|
|
1446
|
-
if res.get(key) is not None:
|
|
1447
|
-
parts.append(f"{key}: {res.get(key)}")
|
|
1448
|
-
content_text = "\n".join(parts)
|
|
1449
|
-
elif name == "mesh_update":
|
|
1450
|
-
res = await call_remote_tool(user, name, args)
|
|
1451
|
-
err = remote_tool_error(res)
|
|
1452
|
-
if err:
|
|
1453
|
-
is_error = True
|
|
1454
|
-
content_text = f"[Error] {err}"
|
|
1455
|
-
else:
|
|
1456
|
-
parts = []
|
|
1457
|
-
if res.get("current"):
|
|
1458
|
-
parts.append(f"node version: {res['current']}")
|
|
1459
|
-
if res.get("latest"):
|
|
1460
|
-
parts.append(f"newest release: {res['latest']}")
|
|
1461
|
-
if res.get("update_available") is not None:
|
|
1462
|
-
parts.append("update available: %s"
|
|
1463
|
-
% ("yes" if res.get("update_available") else "no"))
|
|
1464
|
-
if res.get("applied"):
|
|
1465
|
-
parts.append("applied: %s -> %s"
|
|
1466
|
-
% (res.get("current"), res.get("latest")))
|
|
1467
|
-
parts.append("node is restarting; the tunnel drops for a few seconds")
|
|
1468
|
-
elif res.get("reason"):
|
|
1469
|
-
parts.append(f"detail: {res['reason']}")
|
|
1470
|
-
content_text = "\n".join(parts) if parts else json.dumps(res, ensure_ascii=False, indent=2)
|
|
1471
|
-
elif name == "system_info":
|
|
1472
|
-
res = await call_remote_tool(user, name, args)
|
|
1473
|
-
err = remote_tool_error(res)
|
|
1474
|
-
if err:
|
|
1475
|
-
is_error = True
|
|
1476
|
-
content_text = f"[Error] {err}"
|
|
1477
|
-
else:
|
|
1478
|
-
parts = []
|
|
1479
|
-
# command_shell tells the model which shell bash_exec really uses
|
|
1480
|
-
# (PowerShell or cmd.exe on Windows, bash on Linux), so it writes
|
|
1481
|
-
# commands for the right one instead of assuming bash.
|
|
1482
|
-
for key in ("hostname", "os", "kernel", "user", "home", "desktop",
|
|
1483
|
-
"session_type", "command_shell", "scenario", "device_class",
|
|
1484
|
-
"wallpaper", "wallpaper_exists"):
|
|
1485
|
-
value = res.get(key)
|
|
1486
|
-
if value not in (None, ""):
|
|
1487
|
-
parts.append("%s: %s" % (key, value))
|
|
1488
|
-
for key in ("memory", "load", "disks", "top_processes"):
|
|
1489
|
-
if res.get(key):
|
|
1490
|
-
parts.append("\n[%s]\n%s" % (key, res.get(key)))
|
|
1491
|
-
parts.extend(_device_report_lines(res))
|
|
1492
|
-
content_text = "\n".join(parts) if parts else json.dumps(res, ensure_ascii=False, indent=2)
|
|
1493
|
-
elif name == "system_vitals":
|
|
1494
|
-
res = await call_remote_tool(user, name, args)
|
|
1495
|
-
if "error" in res:
|
|
1496
|
-
is_error = True
|
|
1497
|
-
content_text = f"Error: {res.get('error')}"
|
|
1498
|
-
else:
|
|
1499
|
-
content_text = json.dumps(res, ensure_ascii=False, indent=2)
|
|
1500
|
-
elif name in ("bash_exec", "list_dir", "read_file"):
|
|
1501
|
-
refusal = ""
|
|
1502
|
-
if name == "bash_exec":
|
|
1503
|
-
kind = classify_command(args.get("command"))
|
|
1504
|
-
if kind:
|
|
1505
|
-
# Installs and deletions only run through system_change, which the
|
|
1506
|
-
# client confirms; this tool is advertised as read-only.
|
|
1507
|
-
refusal = command_confirmation_message(args.get("command", ""), kind, "bash_exec")
|
|
1508
|
-
else:
|
|
1509
|
-
# Gateway-level guard: call_remote_tool gives up after 28s, so never
|
|
1510
|
-
# forward a bash timeout longer than 25s (the node defaults to 25).
|
|
1511
|
-
timeout_sec = args.get("timeout_sec")
|
|
1512
|
-
if isinstance(timeout_sec, (int, float)) and timeout_sec > 25:
|
|
1513
|
-
args = dict(args)
|
|
1514
|
-
args["timeout_sec"] = 25
|
|
1515
|
-
if refusal:
|
|
1516
|
-
is_error = True
|
|
1517
|
-
content_text = refusal
|
|
1518
|
-
else:
|
|
1519
|
-
res = await call_remote_tool(user, name, args)
|
|
1520
|
-
content_text, is_error = format_command_result(res, name)
|
|
1521
|
-
elif name in ("write_file", "edit_file"):
|
|
1522
|
-
if is_system_path(args.get("path")):
|
|
1523
|
-
# The unconfirmed file tools never touch the system: that is what
|
|
1524
|
-
# system_write exists for, and the client confirms it.
|
|
1525
|
-
is_error = True
|
|
1526
|
-
content_text = path_confirmation_message(args.get("path", ""), name)
|
|
1527
|
-
res = None
|
|
1528
|
-
else:
|
|
1529
|
-
res = await call_remote_tool(user, name, args)
|
|
1530
|
-
if res is not None:
|
|
1531
|
-
err = remote_tool_error(res)
|
|
1532
|
-
if err:
|
|
1533
|
-
is_error = True
|
|
1534
|
-
content_text = f"[Error] {err}"
|
|
1535
|
-
else:
|
|
1536
|
-
path = res.get("path", args.get("path", ""))
|
|
1537
|
-
sha = res.get("sha256", "")
|
|
1538
|
-
if name == "write_file":
|
|
1539
|
-
content_text = f"OK: wrote {res.get('bytes', 0)} bytes to {path} (sha256={sha})"
|
|
1540
|
-
else:
|
|
1541
|
-
content_text = f"OK: edited {path} ({res.get('replacements', 0)} replacement(s), sha256={sha})"
|
|
1542
|
-
elif name == "system_write":
|
|
1543
|
-
# Confirmed twin of write_file for paths inside the system. The node only
|
|
1544
|
-
# ever sees write_file, so this works with every deployed node version.
|
|
1545
|
-
res = await call_remote_tool(user, "write_file", args)
|
|
1546
|
-
err = remote_tool_error(res)
|
|
1547
|
-
if err:
|
|
1548
|
-
is_error = True
|
|
1549
|
-
content_text = f"[Error] {err}"
|
|
1550
|
-
else:
|
|
1551
|
-
path = res.get("path", args.get("path", ""))
|
|
1552
|
-
sha = res.get("sha256", "")
|
|
1553
|
-
content_text = (
|
|
1554
|
-
f"OK: wrote {res.get('bytes', 0)} bytes to {path} (sha256={sha}) "
|
|
1555
|
-
f"(confirmed system write)"
|
|
1556
|
-
)
|
|
1557
|
-
elif name == "grep_search":
|
|
1558
|
-
res = await call_remote_tool(user, name, args)
|
|
1559
|
-
err = remote_tool_error(res)
|
|
1560
|
-
if err:
|
|
1561
|
-
is_error = True
|
|
1562
|
-
content_text = f"[Error] {err}"
|
|
1563
|
-
else:
|
|
1564
|
-
matches = res.get("matches") or []
|
|
1565
|
-
if not matches:
|
|
1566
|
-
content_text = "No matches."
|
|
1567
|
-
else:
|
|
1568
|
-
lines = []
|
|
1569
|
-
for m in matches:
|
|
1570
|
-
if not isinstance(m, dict):
|
|
1571
|
-
continue
|
|
1572
|
-
lines.append(f"{m.get('path')}:{m.get('line')}: {m.get('text')}")
|
|
1573
|
-
content_text = "\n".join(lines) if lines else "No matches."
|
|
1574
|
-
if res.get("truncated"):
|
|
1575
|
-
content_text += f"\n\n(truncated: showing first {len(lines)} matches; narrow the pattern or raise limit)"
|
|
1576
|
-
elif name == "glob_find":
|
|
1577
|
-
res = await call_remote_tool(user, name, args)
|
|
1578
|
-
err = remote_tool_error(res)
|
|
1579
|
-
if err:
|
|
1580
|
-
is_error = True
|
|
1581
|
-
content_text = f"[Error] {err}"
|
|
1582
|
-
else:
|
|
1583
|
-
files = res.get("files") or []
|
|
1584
|
-
content_text = "\n".join(str(f) for f in files) if files else "No files found."
|
|
1585
|
-
if res.get("truncated"):
|
|
1586
|
-
content_text += f"\n\n(truncated: showing first {len(files)} paths)"
|
|
1587
|
-
elif name == "run_job":
|
|
1588
|
-
kind = classify_command(args.get("command"))
|
|
1589
|
-
if kind:
|
|
1590
|
-
# Same rule as bash_exec: the unconfirmed tools never install or
|
|
1591
|
-
# delete anything, not even in the background.
|
|
1592
|
-
is_error = True
|
|
1593
|
-
content_text = command_confirmation_message(args.get("command", ""), kind, "run_job")
|
|
1594
|
-
else:
|
|
1595
|
-
res = await call_remote_tool(user, name, args)
|
|
1596
|
-
err = remote_tool_error(res)
|
|
1597
|
-
if err:
|
|
1598
|
-
is_error = True
|
|
1599
|
-
content_text = f"[Error] {err}"
|
|
1600
|
-
else:
|
|
1601
|
-
content_text = format_job_started(res, args)
|
|
1602
|
-
elif name == "system_change":
|
|
1603
|
-
# The confirmed twin of bash_exec/run_job. The client asks the user
|
|
1604
|
-
# before this call because the tool is advertised destructiveHint=true;
|
|
1605
|
-
# the node itself only ever sees bash_exec or run_job, so this works with
|
|
1606
|
-
# every node version, including payloads older than this gateway.
|
|
1607
|
-
background = _wants_background(args)
|
|
1608
|
-
forwarded = dict(args)
|
|
1609
|
-
forwarded.pop("background", None)
|
|
1610
|
-
if background:
|
|
1611
|
-
forwarded.pop("timeout_sec", None)
|
|
1612
|
-
forwarded.pop("max_chars", None)
|
|
1613
|
-
forwarded.pop("cursor", None)
|
|
1614
|
-
remote_name = "run_job"
|
|
1615
|
-
else:
|
|
1616
|
-
# bash_exec has no cwd of its own; the command runs in the node's
|
|
1617
|
-
# workspace unless the caller asked for a background job.
|
|
1618
|
-
forwarded.pop("cwd", None)
|
|
1619
|
-
timeout_sec = forwarded.get("timeout_sec")
|
|
1620
|
-
if isinstance(timeout_sec, (int, float)) and timeout_sec > 25:
|
|
1621
|
-
forwarded["timeout_sec"] = 25
|
|
1622
|
-
remote_name = "bash_exec"
|
|
1623
|
-
res = await call_remote_tool(user, remote_name, forwarded)
|
|
1624
|
-
if remote_name == "run_job":
|
|
1625
|
-
err = remote_tool_error(res)
|
|
1626
|
-
if err:
|
|
1627
|
-
is_error = True
|
|
1628
|
-
content_text = f"[Error] {err}"
|
|
1629
|
-
else:
|
|
1630
|
-
content_text = format_job_started(res, forwarded)
|
|
1631
|
-
else:
|
|
1632
|
-
content_text, is_error = format_command_result(res, remote_name)
|
|
1633
|
-
elif name == "job_output":
|
|
1634
|
-
# Gateway-level guard: call_remote_tool gives up after 28s, keep wait below it.
|
|
1635
|
-
wait_ms = args.get("wait_ms")
|
|
1636
|
-
if isinstance(wait_ms, (int, float)) and wait_ms > 20000:
|
|
1637
|
-
args = dict(args)
|
|
1638
|
-
args["wait_ms"] = 20000
|
|
1639
|
-
res = await call_remote_tool(user, name, args)
|
|
1640
|
-
err = remote_tool_error(res)
|
|
1641
|
-
if err:
|
|
1642
|
-
is_error = True
|
|
1643
|
-
content_text = f"[Error] {err}"
|
|
1644
|
-
else:
|
|
1645
|
-
parts = [f"[status={res.get('status')} exit_code={res.get('exit_code')} duration={res.get('duration')}]"]
|
|
1646
|
-
stdout = res.get("stdout") or ""
|
|
1647
|
-
stderr = res.get("stderr") or ""
|
|
1648
|
-
if stdout:
|
|
1649
|
-
parts.append(stdout)
|
|
1650
|
-
if stderr:
|
|
1651
|
-
parts.append(f"[STDERR]\n{stderr}")
|
|
1652
|
-
if res.get("next_cursor") is not None:
|
|
1653
|
-
parts.append(f"... output truncated; continue with cursor={res.get('next_cursor')}")
|
|
1654
|
-
content_text = "\n\n".join(parts)
|
|
1655
|
-
elif name == "job_kill":
|
|
1656
|
-
res = await call_remote_tool(user, name, args)
|
|
1657
|
-
err = remote_tool_error(res)
|
|
1658
|
-
if err:
|
|
1659
|
-
is_error = True
|
|
1660
|
-
content_text = f"[Error] {err}"
|
|
1661
|
-
else:
|
|
1662
|
-
content_text = f"Job {args.get('job_id', '')}: status={res.get('status')} (ok={res.get('ok')})"
|
|
1663
|
-
elif name == "job_list":
|
|
1664
|
-
res = await call_remote_tool(user, name, args)
|
|
1665
|
-
err = remote_tool_error(res)
|
|
1666
|
-
if err:
|
|
1667
|
-
is_error = True
|
|
1668
|
-
content_text = f"[Error] {err}"
|
|
1669
|
-
else:
|
|
1670
|
-
jobs = res.get("jobs") or []
|
|
1671
|
-
if not jobs:
|
|
1672
|
-
content_text = "No jobs."
|
|
1673
|
-
else:
|
|
1674
|
-
lines = ["job_id | status | exit_code | duration | started_at | command"]
|
|
1675
|
-
for j in jobs:
|
|
1676
|
-
if not isinstance(j, dict):
|
|
1677
|
-
continue
|
|
1678
|
-
lines.append(
|
|
1679
|
-
"{job_id} | {status} | {exit_code} | {duration} | {started_at} | {command}".format(
|
|
1680
|
-
job_id=j.get("job_id", ""),
|
|
1681
|
-
status=j.get("status", ""),
|
|
1682
|
-
exit_code=j.get("exit_code"),
|
|
1683
|
-
duration=j.get("duration"),
|
|
1684
|
-
started_at=j.get("started_at", ""),
|
|
1685
|
-
command=j.get("command", ""),
|
|
1686
|
-
)
|
|
1687
|
-
)
|
|
1688
|
-
content_text = "\n".join(lines)
|
|
1689
|
-
elif name in ("share_file", "serve_dir"):
|
|
1690
|
-
res = await call_remote_tool(user, name, args)
|
|
1691
|
-
err = remote_tool_error(res)
|
|
1692
|
-
if err:
|
|
1693
|
-
is_error = True
|
|
1694
|
-
content_text = f"[Error] {err}"
|
|
1695
|
-
else:
|
|
1696
|
-
content_text = (
|
|
1697
|
-
"Published. Public link: {url}\n"
|
|
1698
|
-
"Anyone with this link can read it - the random part of the path is the credential. "
|
|
1699
|
-
"Revoke it with unshare(name=\"{name}\") when it is no longer needed.\n"
|
|
1700
|
-
"Local URL on the node: {local}"
|
|
1701
|
-
).format(url=res.get("url", ""), name=res.get("name", ""),
|
|
1702
|
-
local=res.get("local_url", ""))
|
|
1703
|
-
elif name == "share_list":
|
|
1704
|
-
res = await call_remote_tool(user, name, args)
|
|
1705
|
-
err = remote_tool_error(res)
|
|
1706
|
-
if err:
|
|
1707
|
-
is_error = True
|
|
1708
|
-
content_text = f"[Error] {err}"
|
|
1709
|
-
else:
|
|
1710
|
-
shares = res.get("shares") or []
|
|
1711
|
-
if not shares:
|
|
1712
|
-
content_text = "No active shares."
|
|
1713
|
-
else:
|
|
1714
|
-
lines = ["kind | name | url | running"]
|
|
1715
|
-
for item in shares:
|
|
1716
|
-
if isinstance(item, dict):
|
|
1717
|
-
lines.append("{kind} | {name} | {url} | {running}".format(
|
|
1718
|
-
kind=item.get("kind", ""), name=item.get("name", ""),
|
|
1719
|
-
url=item.get("url", ""), running=item.get("running")))
|
|
1720
|
-
content_text = "\n".join(lines)
|
|
1721
|
-
elif name == "unshare":
|
|
1722
|
-
res = await call_remote_tool(user, name, args)
|
|
1723
|
-
err = remote_tool_error(res)
|
|
1724
|
-
if err:
|
|
1725
|
-
is_error = True
|
|
1726
|
-
content_text = f"[Error] {err}"
|
|
1727
|
-
else:
|
|
1728
|
-
content_text = (
|
|
1729
|
-
"Revoked share '{name}' ({slug}). Server stopped={stopped}, files removed={removed}."
|
|
1730
|
-
).format(name=res.get("name", ""), slug=res.get("slug", ""),
|
|
1731
|
-
stopped=res.get("server_stopped"), removed=res.get("files_removed"))
|
|
1732
|
-
elif name in DEVICE_TOOLS:
|
|
1733
|
-
res = await call_remote_tool(user, name, args)
|
|
1734
|
-
err = remote_tool_error(res)
|
|
1735
|
-
if err:
|
|
1736
|
-
is_error = True
|
|
1737
|
-
content_text = f"[Error] {err}"
|
|
1738
|
-
else:
|
|
1739
|
-
content_text = _format_device_result(name, res)
|
|
1740
|
-
else:
|
|
1741
|
-
resp["error"] = {"code": -32601, "message": f"Unknown tool: {name}"}
|
|
1742
|
-
broadcast_sse(user, resp)
|
|
1743
|
-
session_id = resolve_session_id(request, user)
|
|
1744
|
-
return JSONResponse(
|
|
1745
|
-
resp,
|
|
1746
|
-
headers={
|
|
1747
|
-
"mcp-session-id": session_id,
|
|
1748
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
1749
|
-
"Access-Control-Allow-Origin": "*",
|
|
1750
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
1751
|
-
}
|
|
1752
|
-
)
|
|
1753
|
-
|
|
1754
|
-
# Size guard for the Gemini Web turn budget. The node already paginates
|
|
1755
|
-
# via next_cursor, so the gateway must NOT cut the middle out: that would
|
|
1756
|
-
# break byte-exact continuation. Keep the head and say how to get the
|
|
1757
|
-
# rest, so the model can ask for the next page instead of losing data.
|
|
1758
|
-
MAX_GATEWAY_TEXT = 45000
|
|
1759
|
-
if len(content_text) > MAX_GATEWAY_TEXT:
|
|
1760
|
-
kept = content_text[:MAX_GATEWAY_TEXT]
|
|
1761
|
-
omitted = len(content_text) - MAX_GATEWAY_TEXT
|
|
1762
|
-
content_text = (
|
|
1763
|
-
f"{kept}\n\n... [Output truncated by the gateway: {omitted} more characters. "
|
|
1764
|
-
f"Do not re-run the command; the node can page the rest: call the same tool again "
|
|
1765
|
-
f"with cursor=<next_cursor> and a larger max_chars, or read the saved file if the "
|
|
1766
|
-
f"result reported one.] ..."
|
|
1767
|
-
)
|
|
1768
|
-
|
|
1769
|
-
resp["result"] = {
|
|
1770
|
-
"content": [{"type": "text", "text": content_text}],
|
|
1771
|
-
"isError": is_error
|
|
1772
|
-
}
|
|
1773
|
-
elif method == "prompts/list":
|
|
1774
|
-
resp["result"] = {
|
|
1775
|
-
"prompts": [{"name": "antigravity-orchestrator", "description": f"Orchestrator role for {user}"}]
|
|
1776
|
-
}
|
|
1777
|
-
elif method == "prompts/get":
|
|
1778
|
-
skill_text = get_skill(user, request.headers.get("host", PUBLIC_HOST))
|
|
1779
|
-
resp["result"] = {
|
|
1780
|
-
"description": "Antigravity Orchestrator Persona",
|
|
1781
|
-
"messages": [{"role": "user", "content": {"type": "text", "text": skill_text}}]
|
|
1782
|
-
}
|
|
1783
|
-
elif method == "resources/list":
|
|
1784
|
-
resp["result"] = {
|
|
1785
|
-
"resources": [{
|
|
1786
|
-
"uri": "resource://skills/orchestrator.md",
|
|
1787
|
-
"name": "orchestrator.md",
|
|
1788
|
-
"mimeType": "text/markdown"
|
|
1789
|
-
}]
|
|
1790
|
-
}
|
|
1791
|
-
elif method == "resources/read":
|
|
1792
|
-
uri = params.get("uri")
|
|
1793
|
-
if uri == "resource://skills/orchestrator.md":
|
|
1794
|
-
text = get_skill(user, request.headers.get("host", PUBLIC_HOST))
|
|
1795
|
-
resp["result"] = {"contents": [{"uri": uri, "mimeType": "text/markdown", "text": text}]}
|
|
1796
|
-
else:
|
|
1797
|
-
resp["error"] = {"code": -32602, "message": f"Resource not found: {uri}"}
|
|
1798
|
-
broadcast_sse(user, resp)
|
|
1799
|
-
session_id = resolve_session_id(request, user)
|
|
1800
|
-
return JSONResponse(
|
|
1801
|
-
resp,
|
|
1802
|
-
headers={
|
|
1803
|
-
"mcp-session-id": session_id,
|
|
1804
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
1805
|
-
"Access-Control-Allow-Origin": "*",
|
|
1806
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
1807
|
-
}
|
|
1808
|
-
)
|
|
1809
|
-
else:
|
|
1810
|
-
resp["error"] = {"code": -32601, "message": f"Method not found: {method}"}
|
|
1811
|
-
|
|
1812
|
-
# Delivery channel. The answer always goes in the HTTP body; duplicating it on
|
|
1813
|
-
# the caller's stream is what makes a strict client see one response id twice
|
|
1814
|
-
# and drop the session. Only the legacy transports (POST /messages, or
|
|
1815
|
-
# LEGACY_SSE which answers 202 with no body) are delivered on the stream.
|
|
1816
|
-
path = request.url.path
|
|
1817
|
-
if should_broadcast_response(path):
|
|
1818
|
-
broadcast_sse(user, resp)
|
|
1819
|
-
else:
|
|
1820
|
-
logger.info(f"response [{method} id={req_id}] answered in the POST body only "
|
|
1821
|
-
f"(no stream duplicate)")
|
|
1822
|
-
|
|
1823
|
-
if method == "tools/call":
|
|
1824
|
-
# Duration matters: Gemini's frontend gives a tool call roughly 30 s, so a
|
|
1825
|
-
# slow command shows up here before it shows up as a client-side timeout.
|
|
1826
|
-
logger.info(f"tools/call {params.get('name')} took {time.monotonic() - call_started:.2f}s")
|
|
1827
|
-
|
|
1828
|
-
# Return response in HTTP body (for Streamable HTTP clients) with Mcp-Session-Id header.
|
|
1829
|
-
# A client that asks ONLY for text/event-stream must get an SSE-framed body:
|
|
1830
|
-
# a strict streamable-HTTP client rejects application/json even when the
|
|
1831
|
-
# payload itself is correct.
|
|
1832
|
-
# The id we issued at initialize must be the one the client sees here, or the
|
|
1833
|
-
# client believes it holds a different session than the one it opened.
|
|
1834
|
-
session_id = issued_session_id or resolve_session_id(request, user)
|
|
1835
|
-
headers = {
|
|
1836
|
-
"mcp-session-id": session_id,
|
|
1837
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
1838
|
-
"Access-Control-Allow-Origin": "*",
|
|
1839
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
1840
|
-
}
|
|
1841
|
-
wants_sse_only = ("text/event-stream" in accept_hdr) and ("application/json" not in accept_hdr)
|
|
1842
|
-
if wants_sse_only:
|
|
1843
|
-
payload = "event: message\ndata: %s\n\n" % json.dumps(resp, ensure_ascii=False)
|
|
1844
|
-
headers["Cache-Control"] = "no-cache"
|
|
1845
|
-
return Response(payload, media_type="text/event-stream", headers=headers)
|
|
1846
|
-
|
|
1847
|
-
if (LEGACY_SSE and method != "initialize"
|
|
1848
|
-
and "text/event-stream" in accept_hdr and active_sse_subscribers.get(user)):
|
|
1849
|
-
# The client is holding a stream and reads the answer there, so a body
|
|
1850
|
-
# would be a second copy of the same response. 202 Accepted with no body
|
|
1851
|
-
# is what the legacy SSE transport prescribes; the broadcast above has
|
|
1852
|
-
# already queued the message on the stream.
|
|
1853
|
-
#
|
|
1854
|
-
# `initialize` is deliberately excluded: with it answered 202 the client
|
|
1855
|
-
# never learns the session id or the negotiated version, and the observed
|
|
1856
|
-
# result was a pure initialise/DELETE loop with no tool call at all.
|
|
1857
|
-
logger.info(f"legacy-SSE mode: answering {method} with 202, result goes to the stream")
|
|
1858
|
-
return Response(status_code=202, headers=headers)
|
|
1859
|
-
|
|
1860
|
-
return JSONResponse(resp, headers=headers)
|
|
1861
|
-
|
|
1862
|
-
async def mcp_unified_endpoint(request: Request):
|
|
1863
|
-
if request.method == "OPTIONS":
|
|
1864
|
-
return Response(
|
|
1865
|
-
status_code=204,
|
|
1866
|
-
headers={
|
|
1867
|
-
"Allow": "GET, POST, DELETE, OPTIONS, HEAD",
|
|
1868
|
-
"Access-Control-Allow-Origin": "*",
|
|
1869
|
-
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS, HEAD",
|
|
1870
|
-
"Access-Control-Allow-Headers": "*",
|
|
1871
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
1872
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
1873
|
-
}
|
|
1874
|
-
)
|
|
1875
|
-
if request.method == "DELETE":
|
|
1876
|
-
ok, msg, user = verify_token(request)
|
|
1877
|
-
if not ok:
|
|
1878
|
-
return JSONResponse(
|
|
1879
|
-
{"error": "unauthorized", "message": msg},
|
|
1880
|
-
status_code=401,
|
|
1881
|
-
headers={
|
|
1882
|
-
"Access-Control-Allow-Origin": "*",
|
|
1883
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
1884
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
1885
|
-
}
|
|
1886
|
-
)
|
|
1887
|
-
client_ip = request.client.host if request.client else "unknown"
|
|
1888
|
-
session_id = resolve_session_id(request, user)
|
|
1889
|
-
logger.info(f"Session teardown (DELETE) from {client_ip} [{user}] session_id={session_id[:12]}…")
|
|
1890
|
-
|
|
1891
|
-
# Forget the session we issued for this connection. Streams live under
|
|
1892
|
-
# their own per-connection key, so a teardown can never close a stream
|
|
1893
|
-
# that another in-flight request is still reading.
|
|
1894
|
-
active_sessions.pop(session_id, None)
|
|
1895
|
-
|
|
1896
|
-
# Clean up session queue if active. The lookup stays keyed per connection
|
|
1897
|
-
# (see above): a DELETE closes the stream whose key it names, never
|
|
1898
|
-
# another connection's. The queue is unregistered from the per-user list
|
|
1899
|
-
# and the session index here as well, so /health stays truthful even if
|
|
1900
|
-
# the stream generator never resumes to run its own cleanup.
|
|
1901
|
-
if session_id in active_sse_sessions:
|
|
1902
|
-
q = active_sse_sessions.pop(session_id, None)
|
|
1903
|
-
if q is not None:
|
|
1904
|
-
try:
|
|
1905
|
-
q.put_nowait(None)
|
|
1906
|
-
except Exception:
|
|
1907
|
-
pass
|
|
1908
|
-
forget_sse_queue(user, q)
|
|
1909
|
-
|
|
1910
|
-
return Response(
|
|
1911
|
-
status_code=204,
|
|
1912
|
-
headers={
|
|
1913
|
-
"mcp-session-id": session_id,
|
|
1914
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
1915
|
-
"Access-Control-Allow-Origin": "*",
|
|
1916
|
-
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS, HEAD",
|
|
1917
|
-
"Access-Control-Allow-Headers": "*",
|
|
1918
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
1919
|
-
}
|
|
1920
|
-
)
|
|
1921
|
-
if request.method in ("GET", "HEAD"):
|
|
1922
|
-
path = request.url.path
|
|
1923
|
-
if path == "/messages":
|
|
1924
|
-
ok, msg, user = verify_token(request)
|
|
1925
|
-
if not ok:
|
|
1926
|
-
return JSONResponse(
|
|
1927
|
-
{"error": "unauthorized", "message": msg},
|
|
1928
|
-
status_code=401,
|
|
1929
|
-
headers={
|
|
1930
|
-
"Access-Control-Allow-Origin": "*",
|
|
1931
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
1932
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
1933
|
-
}
|
|
1934
|
-
)
|
|
1935
|
-
accept = (request.headers.get("accept") or "").lower()
|
|
1936
|
-
client_ip = request.client.host if request.client else "unknown"
|
|
1937
|
-
logger.info(
|
|
1938
|
-
f"GET /messages from {client_ip} [{user}] accept={accept[:60]!r} "
|
|
1939
|
-
f"-> {'sse-stream' if 'text/event-stream' in accept else 'json-ready'}"
|
|
1940
|
-
)
|
|
1941
|
-
# Some MCP clients (Gemini Spark among them) open the message endpoint
|
|
1942
|
-
# itself as the server->client stream instead of /sse. Answering that
|
|
1943
|
-
# with a small JSON body closes the stream immediately, which makes the
|
|
1944
|
-
# client drop the session and re-initialise in a loop. Serve a real SSE
|
|
1945
|
-
# stream when the caller asks for one, and keep the JSON readiness probe
|
|
1946
|
-
# for plain health checks.
|
|
1947
|
-
if "text/event-stream" in accept:
|
|
1948
|
-
return await sse_endpoint(request)
|
|
1949
|
-
return JSONResponse({
|
|
1950
|
-
"status": "ready",
|
|
1951
|
-
"endpoint": "messages",
|
|
1952
|
-
"user": user
|
|
1953
|
-
}, headers={
|
|
1954
|
-
"Access-Control-Allow-Origin": "*",
|
|
1955
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
1956
|
-
"mcp-protocol-version": protocol_version_for(user)
|
|
1957
|
-
})
|
|
1958
|
-
return await sse_endpoint(request)
|
|
1959
|
-
if request.method == "POST":
|
|
1960
|
-
return await messages_endpoint(request)
|
|
1961
|
-
return Response(
|
|
1962
|
-
status_code=405,
|
|
1963
|
-
headers={
|
|
1964
|
-
"Allow": "GET, POST, DELETE, OPTIONS, HEAD",
|
|
1965
|
-
"Access-Control-Allow-Origin": "*",
|
|
1966
|
-
"Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
|
|
1967
|
-
"mcp-protocol-version": protocol_version_for(user),
|
|
1968
|
-
}
|
|
1969
|
-
)
|
|
1970
|
-
|
|
1971
|
-
async def ws_tunnel_endpoint(websocket: WebSocket):
|
|
1972
|
-
query = websocket.query_params
|
|
1973
|
-
user = query.get("user", "").lower()
|
|
1974
|
-
token = query.get("token", "")
|
|
1975
|
-
registry = load_registry()
|
|
1976
|
-
|
|
1977
|
-
if not user or user not in registry or not tokens_equal(token, registry[user].get("token", "")):
|
|
1978
|
-
await websocket.close(code=4001, reason="Unauthorized")
|
|
1979
|
-
return
|
|
1980
|
-
|
|
1981
|
-
# Cleanly terminate any prior websocket and fail pending futures for this user
|
|
1982
|
-
old_tunnel = active_tunnels.get(user)
|
|
1983
|
-
if old_tunnel:
|
|
1984
|
-
for r_id, fut in list(old_tunnel.get("pending", {}).items()):
|
|
1985
|
-
if not fut.done():
|
|
1986
|
-
fut.set_exception(ConnectionResetError("Tunnel reconnected"))
|
|
1987
|
-
if old_tunnel.get("ws"):
|
|
1988
|
-
try:
|
|
1989
|
-
await old_tunnel["ws"].close(code=1000, reason="Superceded by new connection")
|
|
1990
|
-
except Exception:
|
|
1991
|
-
pass
|
|
1992
|
-
|
|
1993
|
-
await websocket.accept()
|
|
1994
|
-
logger.info(f"Agent tunnel connected for user '{user}'")
|
|
1995
|
-
tunnel_data = {"ws": websocket, "pending": {}}
|
|
1996
|
-
active_tunnels[user] = tunnel_data
|
|
1997
|
-
# The agent is connected: this is the timestamp /health reports as
|
|
1998
|
-
# last_tunnel_seen, and the event that tells a reboot apart from a flap.
|
|
1999
|
-
record_tunnel_event("connect", user, "agent connected")
|
|
2000
|
-
|
|
2001
|
-
disconnect_reason = "tunnel closed"
|
|
2002
|
-
try:
|
|
2003
|
-
while True:
|
|
2004
|
-
raw = await websocket.receive_text()
|
|
2005
|
-
try:
|
|
2006
|
-
msg = json.loads(raw)
|
|
2007
|
-
except Exception:
|
|
2008
|
-
continue
|
|
2009
|
-
req_id = msg.get("id")
|
|
2010
|
-
if req_id and req_id in tunnel_data["pending"]:
|
|
2011
|
-
fut = tunnel_data["pending"].pop(req_id)
|
|
2012
|
-
if not fut.done():
|
|
2013
|
-
fut.set_result(msg)
|
|
2014
|
-
except WebSocketDisconnect:
|
|
2015
|
-
disconnect_reason = "websocket disconnected"
|
|
2016
|
-
logger.info(f"Agent tunnel disconnected for user '{user}'")
|
|
2017
|
-
except Exception as exc:
|
|
2018
|
-
disconnect_reason = f"tunnel error: {type(exc).__name__}"
|
|
2019
|
-
logger.warning(f"Agent tunnel error for user '{user}': {exc}")
|
|
2020
|
-
raise
|
|
2021
|
-
finally:
|
|
2022
|
-
if active_tunnels.get(user, {}).get("ws") == websocket:
|
|
2023
|
-
active_tunnels.pop(user, None)
|
|
2024
|
-
# Only the tunnel that is still the active one is reported here: if
|
|
2025
|
-
# the watchdog already removed it as stale, or a newer connection
|
|
2026
|
-
# replaced it, that path has its own event and this one is noise.
|
|
2027
|
-
record_tunnel_event("disconnect", user, disconnect_reason)
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
# ---------------------------------------------------------------------------
|
|
2031
|
-
# Public file shares: GET/HEAD <shared-domain>/<node>/<name>-<secret>/...
|
|
2032
|
-
# ---------------------------------------------------------------------------
|
|
2033
|
-
# The node runs a loopback-only static server (core/web_share.py) and this route
|
|
2034
|
-
# relays the request over the existing tunnel; no port is ever opened on the
|
|
2035
|
-
# node. Only read methods are proxied, the 16-hex suffix is the share secret and
|
|
2036
|
-
# a node that is not connected fails fast with 503 instead of blocking on the
|
|
2037
|
-
# tunnel retry loop. The slug shape is duplicated from core/web_share.py on
|
|
2038
|
-
# purpose: the gateway deploys as a single file and must not import node code.
|
|
2039
|
-
SHARE_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}-[0-9a-f]{16}$")
|
|
2040
|
-
SHARE_METHODS = ("GET", "HEAD")
|
|
2041
|
-
# The node is trusted, but a corrupted or buggy reply must not be able to inject
|
|
2042
|
-
# response headers. An allow-list (not a deny-list) also keeps a compromised node
|
|
2043
|
-
# from setting cookies on the shared domain or adding headers this route never
|
|
2044
|
-
# means to expose; control characters are stripped and values are forced through
|
|
2045
|
-
# latin-1, which is what Starlette encodes headers with.
|
|
2046
|
-
_RELAY_HEADERS = (
|
|
2047
|
-
"content-type", "content-disposition", "last-modified", "etag",
|
|
2048
|
-
"cache-control", "location", "accept-ranges", "content-length",
|
|
2049
|
-
)
|
|
2050
|
-
_HEADER_VALUE_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
2051
|
-
_CONTENT_LENGTH_RE = re.compile(r"^\d{1,12}$")
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
def _relay_headers(raw) -> dict:
|
|
2055
|
-
headers = {}
|
|
2056
|
-
if not isinstance(raw, dict):
|
|
2057
|
-
return headers
|
|
2058
|
-
for key, value in raw.items():
|
|
2059
|
-
name = str(key).strip().lower()
|
|
2060
|
-
if name not in _RELAY_HEADERS:
|
|
2061
|
-
continue
|
|
2062
|
-
text = _HEADER_VALUE_RE.sub("", str(value))[:4096]
|
|
2063
|
-
text = text.encode("latin-1", "replace").decode("latin-1")
|
|
2064
|
-
if name == "location" and not text.startswith("/"):
|
|
2065
|
-
continue # a redirect must stay inside this domain's share namespace
|
|
2066
|
-
headers[name] = text
|
|
2067
|
-
return headers
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
# ---------------------------------------------------------------------------
|
|
2071
|
-
# Confirmation policy: what goes through a tool the client confirms
|
|
2072
|
-
# ---------------------------------------------------------------------------
|
|
2073
|
-
# bash_exec, run_job, write_file and edit_file are advertised with
|
|
2074
|
-
# readOnlyHint=true (see the policy below), and Gemini Spark - like any client
|
|
2075
|
-
# that keys its approval dialog on that hint - runs them without asking. That
|
|
2076
|
-
# promise is kept for the classes the operator wants gated, each with a confirmed
|
|
2077
|
-
# twin the model is told to use instead:
|
|
2078
|
-
#
|
|
2079
|
-
# install / delete software is installed or removed, data is deleted
|
|
2080
|
-
# -> system_change
|
|
2081
|
-
# sudo the command escalates privileges (sudo/doas/pkexec/runas)
|
|
2082
|
-
# -> system_change
|
|
2083
|
-
# system a system path or the registry is modified
|
|
2084
|
-
# -> system_change (shell) or system_write (file content)
|
|
2085
|
-
#
|
|
2086
|
-
# The shell check is deliberately conservative: a verb has to sit at the start of a
|
|
2087
|
-
# command segment, after stripping sudo/env/timeout/`powershell -Command`
|
|
2088
|
-
# prefixes, so `echo "rm -rf /"`, `grep rm notes.txt` and `git log --grep install`
|
|
2089
|
-
# all still run. It is a UX gate, not a sandbox - MESH_READ_ONLY on the node is the
|
|
2090
|
-
# hard boundary.
|
|
2091
|
-
_SEGMENT_RE = re.compile(r"[;&|\n]+")
|
|
2092
|
-
_PREFIX_RE = re.compile(
|
|
2093
|
-
r"^\s*(?:(?:sudo|doas|command|nohup|env|time|nice|"
|
|
2094
|
-
r"timeout\s+\d+(?:\.\d+)?|"
|
|
2095
|
-
r"(?:powershell|pwsh)(?:\.exe)?(?:\s+-[\w:]+)*|"
|
|
2096
|
-
r"cmd(?:\.exe)?\s+/[cCkK]|"
|
|
2097
|
-
r"&\s+|start\s+)\s+|\w+=\S+\s+)*"
|
|
2098
|
-
)
|
|
2099
|
-
INSTALL_COMMAND_PATTERNS = (
|
|
2100
|
-
r"apt(?:-get)?\s+(?:install|remove|purge|autoremove|dist-upgrade|full-upgrade)\b",
|
|
2101
|
-
r"aptitude\s+(?:install|remove|purge)\b",
|
|
2102
|
-
r"dnf\s+(?:install|remove|erase|update|upgrade|autoremove|groupinstall)\b",
|
|
2103
|
-
r"yum\s+(?:install|remove|erase|update|upgrade|groupinstall)\b",
|
|
2104
|
-
r"zypper\s+(?:install|remove|rm|update|up|dup|patch)\b",
|
|
2105
|
-
r"pacman\s+-[A-Za-z]*[SR][A-Za-z]*\b",
|
|
2106
|
-
r"apk\s+(?:add|del|delete)\b",
|
|
2107
|
-
r"brew\s+(?:install|uninstall|remove|upgrade|reinstall)\b",
|
|
2108
|
-
r"(?:pip|pip3|pipx)\s+(?:install|uninstall)\b",
|
|
2109
|
-
r"(?:python|python3|py)\s+-m\s+pip\s+(?:install|uninstall)\b",
|
|
2110
|
-
r"(?:npm|pnpm|yarn)\s+(?:install|i|ci|add|uninstall|remove|rm|up|upgrade)\b",
|
|
2111
|
-
r"winget\s+(?:install|uninstall|upgrade)\b",
|
|
2112
|
-
r"(?:choco|scoop)\s+(?:install|uninstall|upgrade|remove)\b",
|
|
2113
|
-
r"(?:snap|flatpak)\s+(?:install|remove|uninstall|refresh|update)\b",
|
|
2114
|
-
r"(?:gem|cargo|dotnet)\s+(?:install|uninstall|tool\s+(?:install|uninstall)|"
|
|
2115
|
-
r"add\s+package|remove\s+package)\b",
|
|
2116
|
-
r"go\s+install\b",
|
|
2117
|
-
r"msiexec(?:\.exe)?\s+/[ix]\b",
|
|
2118
|
-
r"dpkg\s+-[A-Za-z]*[irP][A-Za-z]*\b",
|
|
2119
|
-
r"rpm\s+-[A-Za-z]*[ieU][A-Za-z]*\b",
|
|
2120
|
-
r"(?:install|uninstall)-(?:module|package|script|psresource|windowsfeature)\b",
|
|
2121
|
-
r"(?:add|remove)-(?:appxpackage|windowsoptionalfeature|windowsfeature|"
|
|
2122
|
-
r"provisionedappxpackage)\b",
|
|
2123
|
-
r"\b(?:iex|invoke-expression)\b",
|
|
2124
|
-
)
|
|
2125
|
-
DELETE_COMMAND_PATTERNS = (
|
|
2126
|
-
r"rm\s+(?:-\S+\s+)*\S",
|
|
2127
|
-
r"(?:rmdir|rd|unlink|shred|srm)\s+\S",
|
|
2128
|
-
r"(?:del|erase)\s+\S",
|
|
2129
|
-
r"deltree\b",
|
|
2130
|
-
r"remove-item\b",
|
|
2131
|
-
r"ri\s+(?:-\S+\s+)*\S",
|
|
2132
|
-
r"clear-content\b",
|
|
2133
|
-
r"remove-itemproperty\b",
|
|
2134
|
-
r"(?:format-volume|clear-disk|remove-partition)\b",
|
|
2135
|
-
r"(?:mkfs(?:\.\w+)?|wipefs|blkdiscard|fdisk|parted)\b",
|
|
2136
|
-
r"dd\s+",
|
|
2137
|
-
r"truncate\s+-s\s*0\b",
|
|
2138
|
-
r"git\s+clean\s+-\S*[fdx]",
|
|
2139
|
-
r"git\s+rm\b",
|
|
2140
|
-
r"docker\s+(?:rm|rmi)\b",
|
|
2141
|
-
r"docker\s+(?:system|volume|image|container|builder)\s+prune\b",
|
|
2142
|
-
r"kubectl\s+delete\b",
|
|
2143
|
-
r"vssadmin\s+delete\b",
|
|
2144
|
-
r"bcdedit\s+/delete\b",
|
|
2145
|
-
r"(?:userdel|groupdel|deluser|delgroup)\b",
|
|
2146
|
-
)
|
|
2147
|
-
_INSTALL_RE = re.compile("|".join(INSTALL_COMMAND_PATTERNS), re.IGNORECASE)
|
|
2148
|
-
_DELETE_RE = re.compile("|".join(DELETE_COMMAND_PATTERNS), re.IGNORECASE)
|
|
2149
|
-
# `curl … | sh` is the installer idiom and never looks like a package manager.
|
|
2150
|
-
_PIPE_TO_SHELL_RE = re.compile(
|
|
2151
|
-
r"\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba|z|k)?sh\b", re.IGNORECASE
|
|
2152
|
-
)
|
|
2153
|
-
# Privilege escalation: the operator asked to be asked whenever the agent reaches
|
|
2154
|
-
# for root, whatever the command does with it.
|
|
2155
|
-
_PRIVILEGE_RE = re.compile(
|
|
2156
|
-
r"(?:^|[;&|\n])\s*(?:sudo|doas|gsudo|pkexec|runas)\b"
|
|
2157
|
-
r"|\bsu\s+-\S"
|
|
2158
|
-
r"|\bStart-Process\b[^\n]*?-Verb\s+RunAs",
|
|
2159
|
-
re.IGNORECASE,
|
|
2160
|
-
)
|
|
2161
|
-
# Paths that belong to the system, not to the user's work: the operator asked to be
|
|
2162
|
-
# asked before these are modified. Reads (`cat /etc/os-release`, `ls /usr/bin`) are
|
|
2163
|
-
# not gated - only a mutation of them is.
|
|
2164
|
-
SYSTEM_PATH_RE = re.compile(
|
|
2165
|
-
r"(?:"
|
|
2166
|
-
r"/(?:etc|usr|bin|sbin|lib|lib64|boot|dev|proc|sys|opt|root)(?:/|\b)"
|
|
2167
|
-
r"|/var/(?:lib|log|spool|cache|www)(?:/|\b)"
|
|
2168
|
-
r"|[A-Za-z]:\\+(?:Windows|ProgramData)(?:\\|\b)"
|
|
2169
|
-
r"|[A-Za-z]:\\+Program Files(?:\s*\(x86\))?(?:\\|\b)"
|
|
2170
|
-
r"|%(?:SystemRoot|ProgramFiles|ProgramData|windir)%"
|
|
2171
|
-
r"|HKEY_LOCAL_MACHINE|HKLM\\+"
|
|
2172
|
-
r")",
|
|
2173
|
-
re.IGNORECASE,
|
|
2174
|
-
)
|
|
2175
|
-
# What "modifies" means for the test above.
|
|
2176
|
-
SYSTEM_MUTATION_PATTERNS = (
|
|
2177
|
-
r"rm|rmdir|unlink|shred|mv|cp|install|chmod|chown|chgrp|mkdir|rmdir|touch|ln|"
|
|
2178
|
-
r"truncate|dd|mount|umount|tee|sed\s+-i|patch|dpkg|rpm|apt(?:-get)?|"
|
|
2179
|
-
r"(?:systemctl|service)\s+(?:start|stop|restart|reload|enable|disable|mask|unmask|"
|
|
2180
|
-
r"daemon-reload|set-property)|"
|
|
2181
|
-
r"crontab|visudo|passwd|user(?:add|mod|del)|group(?:add|mod|del)|"
|
|
2182
|
-
r"reg\s+(?:add|delete|import|copy|restore|save|load|unload)|"
|
|
2183
|
-
r"sc\s+(?:config|create|delete|start|stop)|netsh|schtasks|icacls|takeown|attrib|"
|
|
2184
|
-
r"new-item|set-content|add-content|out-file|remove-item|move-item|copy-item|"
|
|
2185
|
-
r"rename-item|new-itemproperty|set-itemproperty|clear-content|set-acl|"
|
|
2186
|
-
r"(?:start|stop|restart|new|set|remove)-service"
|
|
2187
|
-
)
|
|
2188
|
-
_SYSTEM_MUTATION_RE = re.compile(
|
|
2189
|
-
r"(?<![\w.-])(?:%s)(?![\w.-])" % SYSTEM_MUTATION_PATTERNS, re.IGNORECASE
|
|
2190
|
-
)
|
|
2191
|
-
# A redirection that writes straight into a system path.
|
|
2192
|
-
_REDIRECT_TO_SYSTEM_RE = re.compile(
|
|
2193
|
-
r">>?\s*[\"']?(?:"
|
|
2194
|
-
r"/(?:etc|usr|bin|sbin|lib|lib64|boot|dev|proc|sys|opt|root)(?:/|\b)"
|
|
2195
|
-
r"|/var/(?:lib|log|spool|cache|www)(?:/|\b)"
|
|
2196
|
-
r"|[A-Za-z]:\\+(?:Windows|ProgramData)(?:\\|\b)"
|
|
2197
|
-
r"|[A-Za-z]:\\+Program Files(?:\s*\(x86\))?(?:\\|\b)"
|
|
2198
|
-
r")",
|
|
2199
|
-
re.IGNORECASE,
|
|
2200
|
-
)
|
|
2201
|
-
_TRUE_VALUES = ("1", "true", "yes", "on")
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
def _wants_background(args) -> bool:
|
|
2205
|
-
"""True when the caller asked for system_change in the background."""
|
|
2206
|
-
if not isinstance(args, dict):
|
|
2207
|
-
return False
|
|
2208
|
-
value = args.get("background")
|
|
2209
|
-
if isinstance(value, bool):
|
|
2210
|
-
return value
|
|
2211
|
-
return str(value or "").strip().lower() in _TRUE_VALUES
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
def classify_command(command) -> str:
|
|
2215
|
-
"""Return "install", "delete", "sudo", "system" or "" for a shell command.
|
|
2216
|
-
|
|
2217
|
-
"" means the command may run in the unconfirmed tools; anything else has to go
|
|
2218
|
-
through system_change, where the client asks the user first. The most specific
|
|
2219
|
-
reason wins, so `sudo apt install x` reports the install, not the sudo.
|
|
2220
|
-
"""
|
|
2221
|
-
if not isinstance(command, str) or not command.strip():
|
|
2222
|
-
return ""
|
|
2223
|
-
if _PIPE_TO_SHELL_RE.search(command):
|
|
2224
|
-
return "install"
|
|
2225
|
-
for segment in _SEGMENT_RE.split(command):
|
|
2226
|
-
stripped = _PREFIX_RE.sub("", segment).strip().lstrip("\"'`").lstrip()
|
|
2227
|
-
if not stripped:
|
|
2228
|
-
continue
|
|
2229
|
-
if _INSTALL_RE.match(stripped):
|
|
2230
|
-
return "install"
|
|
2231
|
-
if _DELETE_RE.match(stripped):
|
|
2232
|
-
return "delete"
|
|
2233
|
-
if _PRIVILEGE_RE.search(command):
|
|
2234
|
-
return "sudo"
|
|
2235
|
-
if SYSTEM_PATH_RE.search(command) and (
|
|
2236
|
-
_SYSTEM_MUTATION_RE.search(command) or _REDIRECT_TO_SYSTEM_RE.search(command)):
|
|
2237
|
-
return "system"
|
|
2238
|
-
return ""
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
def is_system_path(path) -> bool:
|
|
2242
|
-
"""True when *path* points into the system rather than the user's own files."""
|
|
2243
|
-
if not isinstance(path, str) or not path.strip():
|
|
2244
|
-
return False
|
|
2245
|
-
return bool(SYSTEM_PATH_RE.search(path.strip()))
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
def command_confirmation_message(command: str, kind: str, tool: str) -> str:
|
|
2249
|
-
"""The refusal a model gets instead of running a gated command unconfirmed."""
|
|
2250
|
-
what = {
|
|
2251
|
-
"install": "installs or removes software",
|
|
2252
|
-
"delete": "deletes data",
|
|
2253
|
-
"sudo": "runs with elevated privileges",
|
|
2254
|
-
"system": "changes a system path",
|
|
2255
|
-
}.get(kind, "is a system change")
|
|
2256
|
-
return (
|
|
2257
|
-
f"[Confirmation required] This command {what}, so it was not run.\n"
|
|
2258
|
-
f"{tool} is advertised as read-only and therefore runs without a confirmation "
|
|
2259
|
-
f"dialog; the operator's rule is to be asked before anything is installed, "
|
|
2260
|
-
f"deleted, run as root, or written into a system path. Call system_change with "
|
|
2261
|
-
f"the same command - that tool is advertised as destructive, so the user is "
|
|
2262
|
-
f"asked to confirm it first.\n"
|
|
2263
|
-
f"command: {command.strip() if isinstance(command, str) else command}\n"
|
|
2264
|
-
f"Do not rephrase, split, encode or otherwise obfuscate the command to get around "
|
|
2265
|
-
f"this; route it through system_change."
|
|
2266
|
-
)
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
def path_confirmation_message(path: str, tool: str) -> str:
|
|
2270
|
-
"""The refusal for write_file/edit_file aimed at a system path."""
|
|
2271
|
-
return (
|
|
2272
|
-
f"[Confirmation required] {path} is a system path, so it was not written.\n"
|
|
2273
|
-
f"{tool} is advertised as read-only and runs without a confirmation dialog; the "
|
|
2274
|
-
f"operator's rule is to be asked before anything outside their own files is "
|
|
2275
|
-
f"changed. Use system_write(path=..., content=...) instead - it is advertised as "
|
|
2276
|
-
f"destructive, so the user is asked to confirm the write first.\n"
|
|
2277
|
-
f"path: {path}"
|
|
2278
|
-
)
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
def format_command_result(res, name: str):
|
|
2282
|
-
"""(text, is_error) for a bash_exec / system_change / list_dir / read_file reply."""
|
|
2283
|
-
if "error" in res:
|
|
2284
|
-
return (f"[Execution Error]: {res.get('error')}", True)
|
|
2285
|
-
is_error = False
|
|
2286
|
-
exit_code = res.get("exit_code", 0)
|
|
2287
|
-
# Never strip: a paginated chunk must survive byte-exact so the
|
|
2288
|
-
# client can rebuild the output by following next_cursor.
|
|
2289
|
-
stdout = res.get("stdout", "")
|
|
2290
|
-
stderr = res.get("stderr", "")
|
|
2291
|
-
if exit_code != 0:
|
|
2292
|
-
is_error = True
|
|
2293
|
-
# A non-zero exit code is the command's own result, not a
|
|
2294
|
-
# transport failure: the command RAN on the host. Without this
|
|
2295
|
-
# wording a model reads isError=true as "the node is
|
|
2296
|
-
# unreachable" and stops looking for the real output.
|
|
2297
|
-
parts = [
|
|
2298
|
-
f"[Exit code: {exit_code}] The command ran on the host and the node "
|
|
2299
|
-
f"is reachable; {exit_code} is the command's own exit status."
|
|
2300
|
-
]
|
|
2301
|
-
if stderr.strip():
|
|
2302
|
-
parts.append(stderr)
|
|
2303
|
-
if stdout.strip():
|
|
2304
|
-
parts.append(stdout)
|
|
2305
|
-
content_text = "\n".join(parts) if parts else f"[Exit code: {exit_code}]"
|
|
2306
|
-
else:
|
|
2307
|
-
parts = []
|
|
2308
|
-
if stdout:
|
|
2309
|
-
parts.append(stdout)
|
|
2310
|
-
if stderr:
|
|
2311
|
-
parts.append(f"[STDERR]\n{stderr}")
|
|
2312
|
-
content_text = "\n\n".join(parts) if parts else "(Command executed successfully with no output)"
|
|
2313
|
-
# Surface node-side pagination/spooling so the model knows there is more.
|
|
2314
|
-
if isinstance(res, dict):
|
|
2315
|
-
if res.get("next_cursor") is not None:
|
|
2316
|
-
content_text += (
|
|
2317
|
-
f"\n\n[output continues on the node: call {name} again with "
|
|
2318
|
-
f"cursor={res.get('next_cursor')} (optionally a larger max_chars)]"
|
|
2319
|
-
)
|
|
2320
|
-
if res.get("saved_to"):
|
|
2321
|
-
content_text += f"\n\n[full output saved on the node at {res['saved_to']}]"
|
|
2322
|
-
return (content_text, is_error)
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
def format_job_started(res, args) -> str:
|
|
2326
|
-
"""The reply for run_job and for system_change(background=true)."""
|
|
2327
|
-
job_id = res.get("job_id", "")
|
|
2328
|
-
return (
|
|
2329
|
-
f"Started job {job_id} (pid {res.get('pid')})\n"
|
|
2330
|
-
f"command: {res.get('command', (args or {}).get('command', ''))}\n"
|
|
2331
|
-
f"Use job_output(job_id=\"{job_id}\") to poll progress; "
|
|
2332
|
-
f"use job_kill(job_id=\"{job_id}\") to stop it."
|
|
2333
|
-
)
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
# ---------------------------------------------------------------------------
|
|
2337
|
-
# MCP tool annotations: when the client may run a tool without asking first
|
|
2338
|
-
# ---------------------------------------------------------------------------
|
|
2339
|
-
# Gemini Spark reads ``Tool.annotations`` from ``tools/list`` to decide whether it
|
|
2340
|
-
# must stop and ask the user "confirm this action?" before a call. The spec's
|
|
2341
|
-
# defaults are pessimistic (readOnlyHint false, destructiveHint true), so a tool
|
|
2342
|
-
# advertised with no annotations is treated as destructive and *every* call was
|
|
2343
|
-
# confirmed. Declaring the hints explicitly keeps the prompt for the calls the
|
|
2344
|
-
# operator wants confirmed - installing/deleting, sudo, system paths - and removes
|
|
2345
|
-
# it from everything else.
|
|
2346
|
-
#
|
|
2347
|
-
# This table is the gateway's own: the gateway deploys as one file and may not
|
|
2348
|
-
# import node code, so tests/test_gateway_share_route.py compares the advertised
|
|
2349
|
-
# hints with the node's TOOLS. The differences are deliberate and pinned there:
|
|
2350
|
-
# system_change and system_write exist only here (they are translated to
|
|
2351
|
-
# bash_exec/run_job/write_file before the node sees them), and every tool this
|
|
2352
|
-
# gateway silences is still advertised honestly by the node, whose own surface has
|
|
2353
|
-
# no classifier to keep the read-only claim true.
|
|
2354
|
-
_ANNOT_READ_ONLY = {
|
|
2355
|
-
"readOnlyHint": True,
|
|
2356
|
-
"destructiveHint": False,
|
|
2357
|
-
"idempotentHint": True,
|
|
2358
|
-
"openWorldHint": False,
|
|
2359
|
-
}
|
|
2360
|
-
#: Tools that change something but are deliberately left without a dialog: file
|
|
2361
|
-
#: writes the operator asked not to confirm, and the unconfirmed shell pair whose
|
|
2362
|
-
#: gated classes are refused by classify_command() above.
|
|
2363
|
-
_ANNOT_UNCONFIRMED_WRITE = {
|
|
2364
|
-
"readOnlyHint": True,
|
|
2365
|
-
"destructiveHint": False,
|
|
2366
|
-
"idempotentHint": False,
|
|
2367
|
-
"openWorldHint": False,
|
|
2368
|
-
}
|
|
2369
|
-
_ANNOT_LOCAL_WRITE = {
|
|
2370
|
-
"readOnlyHint": False,
|
|
2371
|
-
"destructiveHint": False,
|
|
2372
|
-
"idempotentHint": False,
|
|
2373
|
-
"openWorldHint": False,
|
|
2374
|
-
}
|
|
2375
|
-
#: Publishing hands out a password-like public link, so the client is told the
|
|
2376
|
-
#: tool reaches outside the host (openWorldHint). It is still not destructive.
|
|
2377
|
-
_ANNOT_PUBLISH = {
|
|
2378
|
-
"readOnlyHint": True,
|
|
2379
|
-
"destructiveHint": False,
|
|
2380
|
-
"idempotentHint": False,
|
|
2381
|
-
"openWorldHint": True,
|
|
2382
|
-
}
|
|
2383
|
-
_ANNOT_DESTRUCTIVE = {
|
|
2384
|
-
"readOnlyHint": False,
|
|
2385
|
-
"destructiveHint": True,
|
|
2386
|
-
"idempotentHint": False,
|
|
2387
|
-
"openWorldHint": False,
|
|
2388
|
-
}
|
|
2389
|
-
#: Camera, microphone, location, SMS and contacts. They delete nothing, so
|
|
2390
|
-
#: "destructive" is not literally true - but ``destructiveHint`` is the only hint
|
|
2391
|
-
#: clients such as Gemini Spark reliably turn into a "confirm this action?" prompt,
|
|
2392
|
-
#: and these calls observe the physical world (or a person's private messages)
|
|
2393
|
-
#: without the operator being able to see it happen. Marking them destructive is
|
|
2394
|
-
#: therefore the honest safety choice, and it is documented in the README.
|
|
2395
|
-
_ANNOT_SENSITIVE = {
|
|
2396
|
-
"readOnlyHint": False,
|
|
2397
|
-
"destructiveHint": True,
|
|
2398
|
-
"idempotentHint": False,
|
|
2399
|
-
"openWorldHint": False,
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
#: Confirmation policy per advertised tool; names must match core/mcp_tools.TOOLS.
|
|
2403
|
-
#: A tool left out stays unannotated on purpose and therefore asks for
|
|
2404
|
-
#: confirmation - the safe default for something nobody has judged yet.
|
|
2405
|
-
TOOL_ANNOTATIONS = {
|
|
2406
|
-
# read-only: looking around never needs a prompt
|
|
2407
|
-
"mesh_status": _ANNOT_READ_ONLY,
|
|
2408
|
-
"system_info": _ANNOT_READ_ONLY,
|
|
2409
|
-
"system_vitals": _ANNOT_READ_ONLY,
|
|
2410
|
-
"get_orchestration_skill": _ANNOT_READ_ONLY,
|
|
2411
|
-
"list_dir": _ANNOT_READ_ONLY,
|
|
2412
|
-
"read_file": _ANNOT_READ_ONLY,
|
|
2413
|
-
"grep_search": _ANNOT_READ_ONLY,
|
|
2414
|
-
"glob_find": _ANNOT_READ_ONLY,
|
|
2415
|
-
"job_output": _ANNOT_READ_ONLY,
|
|
2416
|
-
"job_list": _ANNOT_READ_ONLY,
|
|
2417
|
-
"share_list": _ANNOT_READ_ONLY,
|
|
2418
|
-
"device_info": _ANNOT_READ_ONLY,
|
|
2419
|
-
# Commands run without a dialog; classify_command() refuses the gated classes
|
|
2420
|
-
# (install/delete, sudo, system paths) and routes them to system_change.
|
|
2421
|
-
"bash_exec": _ANNOT_UNCONFIRMED_WRITE,
|
|
2422
|
-
"run_job": _ANNOT_UNCONFIRMED_WRITE,
|
|
2423
|
-
# Files are written without a dialog too; a write into a system path is refused
|
|
2424
|
-
# and routed to system_write, which the user confirms.
|
|
2425
|
-
"write_file": _ANNOT_UNCONFIRMED_WRITE,
|
|
2426
|
-
"edit_file": _ANNOT_UNCONFIRMED_WRITE,
|
|
2427
|
-
# Stopping a job is neither an install nor a delete, and bash_exec can
|
|
2428
|
-
# terminate the same process anyway, so no prompt is spent on it.
|
|
2429
|
-
"job_kill": _ANNOT_UNCONFIRMED_WRITE,
|
|
2430
|
-
# Reversible actions on the node's own device (torch, volume, TTS, ...): they
|
|
2431
|
-
# change state, but nothing is deleted, nothing is gated and nothing leaves the
|
|
2432
|
-
# host. The node advertises the same hints and a test compares the two, so this
|
|
2433
|
-
# stays the plain local-write profile rather than the classifier-backed one.
|
|
2434
|
-
"device_control": _ANNOT_LOCAL_WRITE,
|
|
2435
|
-
# publishing something on the internet: no dialog either, but the client is told
|
|
2436
|
-
# the tool reaches outside the host
|
|
2437
|
-
"share_file": _ANNOT_PUBLISH,
|
|
2438
|
-
"serve_dir": _ANNOT_PUBLISH,
|
|
2439
|
-
# destructive: install/delete, sudo, system paths -> confirm first
|
|
2440
|
-
"system_change": _ANNOT_DESTRUCTIVE,
|
|
2441
|
-
"system_write": _ANNOT_DESTRUCTIVE,
|
|
2442
|
-
"mesh_update": _ANNOT_DESTRUCTIVE,
|
|
2443
|
-
"unshare": _ANNOT_DESTRUCTIVE,
|
|
2444
|
-
# sensitive: observing the world or a person's private messages. Nothing is
|
|
2445
|
-
# deleted, but the client must always ask - see _ANNOT_SENSITIVE above.
|
|
2446
|
-
"device_capture": _ANNOT_SENSITIVE,
|
|
2447
|
-
"device_messages": _ANNOT_SENSITIVE,
|
|
2448
|
-
}
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
def annotate_tools(tools):
|
|
2452
|
-
"""Return *tools* with the confirmation policy attached to each entry."""
|
|
2453
|
-
annotated = []
|
|
2454
|
-
for tool in tools:
|
|
2455
|
-
profile = TOOL_ANNOTATIONS.get(tool.get("name"))
|
|
2456
|
-
if profile is None:
|
|
2457
|
-
annotated.append(tool)
|
|
2458
|
-
continue
|
|
2459
|
-
tool = dict(tool)
|
|
2460
|
-
tool["annotations"] = dict(profile)
|
|
2461
|
-
annotated.append(tool)
|
|
2462
|
-
return annotated
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
# Tool surface advertised for public shares. The gateway keeps its own static
|
|
2466
|
-
# tools/list (it must not import node code), so this list has to stay in step
|
|
2467
|
-
# with the node's core/mcp_tools.TOOLS; tests/test_gateway_share_route.py
|
|
2468
|
-
# compares the two surfaces to catch drift.
|
|
2469
|
-
SHARE_TOOL_SPECS = [
|
|
2470
|
-
{
|
|
2471
|
-
"name": "share_file",
|
|
2472
|
-
"description": (
|
|
2473
|
-
"Publish ONE local file on the internet through the gateway and return a public "
|
|
2474
|
-
"HTTPS link (https://<domain>/<node>/<name>-<random>/<file>). Anyone with the link "
|
|
2475
|
-
"can read it; revoke it with unshare when it is no longer needed."
|
|
2476
|
-
),
|
|
2477
|
-
"inputSchema": {
|
|
2478
|
-
"type": "object",
|
|
2479
|
-
"properties": {
|
|
2480
|
-
"path": {"type": "string", "description": "Path of the local file to publish."},
|
|
2481
|
-
"name": {"type": "string", "description": "Human name in the URL (default: file name)."},
|
|
2482
|
-
"overwrite": {"type": "boolean", "description": "Replace an existing share with the same name."},
|
|
2483
|
-
},
|
|
2484
|
-
"required": ["path"],
|
|
2485
|
-
},
|
|
2486
|
-
},
|
|
2487
|
-
{
|
|
2488
|
-
"name": "serve_dir",
|
|
2489
|
-
"description": (
|
|
2490
|
-
"Start a static web server for a local directory and return its public HTTPS link "
|
|
2491
|
-
"(https://<domain>/<node>/<name>-<random>/). Served in place, read-only (GET/HEAD); "
|
|
2492
|
-
"unshare stops it and the directory itself is never deleted."
|
|
2493
|
-
),
|
|
2494
|
-
"inputSchema": {
|
|
2495
|
-
"type": "object",
|
|
2496
|
-
"properties": {
|
|
2497
|
-
"path": {"type": "string", "description": "Local directory to serve."},
|
|
2498
|
-
"name": {"type": "string", "description": "Human name in the URL (default: directory name)."},
|
|
2499
|
-
},
|
|
2500
|
-
"required": ["path"],
|
|
2501
|
-
},
|
|
2502
|
-
},
|
|
2503
|
-
{
|
|
2504
|
-
"name": "share_list",
|
|
2505
|
-
"description": "List active public shares with their URLs and whether the local server is running.",
|
|
2506
|
-
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
|
2507
|
-
},
|
|
2508
|
-
{
|
|
2509
|
-
"name": "unshare",
|
|
2510
|
-
"description": "Stop a public share and revoke its link (accepts the name, slug or full URL).",
|
|
2511
|
-
"inputSchema": {
|
|
2512
|
-
"type": "object",
|
|
2513
|
-
"properties": {
|
|
2514
|
-
"name": {"type": "string", "description": "Share name, slug or public URL from share_list."},
|
|
2515
|
-
},
|
|
2516
|
-
"required": ["name"],
|
|
2517
|
-
},
|
|
2518
|
-
},
|
|
2519
|
-
]
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
# The node's own device: one relay branch in tools/call, one formatter. The four
|
|
2523
|
-
# names must match core/mcp_tools.TOOLS exactly (the same parity test as the
|
|
2524
|
-
# share tools above), and DEVICE_TOOLS is deliberately a tuple so the branch is a
|
|
2525
|
-
# single membership test instead of four elifs.
|
|
2526
|
-
#
|
|
2527
|
-
# core/mcp_tools.TOOLS is the source of truth for the device_* specs in tools/list
|
|
2528
|
-
# as well. Their descriptions and schemas above are copied from it verbatim; an
|
|
2529
|
-
# earlier draft of this file carried different prose, which the node's landed
|
|
2530
|
-
# version superseded. The node executes these calls and the client reads this
|
|
2531
|
-
# copy, so a wording difference here would be a lie the model acts on.
|
|
2532
|
-
DEVICE_TOOLS = ("device_info", "device_control", "device_capture", "device_messages")
|
|
2533
|
-
|
|
2534
|
-
#: A device report is JSON by contract and ``device_info(section="all")`` can run
|
|
2535
|
-
#: to megabytes, so the gateway shows the head and names the way to ask for one
|
|
2536
|
-
#: block by itself - rather than cutting a table in the middle with no way back.
|
|
2537
|
-
DEVICE_REPORT_LIMIT = 8000
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
def _device_report_lines(res):
|
|
2541
|
-
"""Render the device blocks of a ``system_info`` answer as readable lines.
|
|
2542
|
-
|
|
2543
|
-
The gateway used to print a fixed list of the original fields, which silently
|
|
2544
|
-
dropped everything the device branch added - the client asked for the battery
|
|
2545
|
-
and got a summary without it. Every block is rendered as either its facts or,
|
|
2546
|
-
when the platform has no source for it, ``available: false`` with the reason and
|
|
2547
|
-
the fix, because "not exposed here" and "0 %" must not look the same to a model.
|
|
2548
|
-
"""
|
|
2549
|
-
if not isinstance(res, dict):
|
|
2550
|
-
return []
|
|
2551
|
-
lines = []
|
|
2552
|
-
hint = res.get("device_hint")
|
|
2553
|
-
if hint:
|
|
2554
|
-
lines.append("\n[device] %s" % hint)
|
|
2555
|
-
|
|
2556
|
-
def block(name):
|
|
2557
|
-
value = res.get(name)
|
|
2558
|
-
return value if isinstance(value, dict) else None
|
|
2559
|
-
|
|
2560
|
-
def facts(name, fields, prefix=""):
|
|
2561
|
-
value = block(name)
|
|
2562
|
-
if value is None:
|
|
2563
|
-
return
|
|
2564
|
-
if value.get("available") is False:
|
|
2565
|
-
line = "%s%s: not available - %s" % (prefix, name, value.get("reason") or "no reason given")
|
|
2566
|
-
if value.get("fix"):
|
|
2567
|
-
line += " (fix: %s)" % value["fix"]
|
|
2568
|
-
lines.append(line)
|
|
2569
|
-
return
|
|
2570
|
-
shown = ["%s=%s" % (key, value[key]) for key in fields if value.get(key) not in (None, "")]
|
|
2571
|
-
if shown:
|
|
2572
|
-
lines.append("%s%s: %s" % (prefix, name, ", ".join(shown)))
|
|
2573
|
-
|
|
2574
|
-
device = block("device")
|
|
2575
|
-
if device and device.get("available") is not False:
|
|
2576
|
-
shown = [device.get(key) for key in ("class", "manufacturer", "model", "android_release")]
|
|
2577
|
-
shown = [str(item) for item in shown if item]
|
|
2578
|
-
if shown:
|
|
2579
|
-
lines.append("\n[device] " + " ".join(shown))
|
|
2580
|
-
facts("battery", ("percent", "status", "plugged", "temperature_c", "health",
|
|
2581
|
-
"time_remaining_s", "ac_online"))
|
|
2582
|
-
network = block("network")
|
|
2583
|
-
if network:
|
|
2584
|
-
if network.get("available") is False:
|
|
2585
|
-
facts("network", ())
|
|
2586
|
-
else:
|
|
2587
|
-
if network.get("signal"):
|
|
2588
|
-
lines.append("signal: %s" % network["signal"])
|
|
2589
|
-
shown = [network.get(key) for key in ("connected_kind", "interface_count",
|
|
2590
|
-
"default_route")]
|
|
2591
|
-
shown = [str(item) for item in shown if item not in (None, "")]
|
|
2592
|
-
if shown:
|
|
2593
|
-
lines.append("network: " + ", ".join(shown))
|
|
2594
|
-
wifi = network.get("wifi") or {}
|
|
2595
|
-
if wifi:
|
|
2596
|
-
detail = ["%s=%s" % (key, wifi[key]) for key in ("ssid", "rssi_dbm",
|
|
2597
|
-
"link_speed_mbps", "frequency_mhz")
|
|
2598
|
-
if wifi.get(key) not in (None, "")]
|
|
2599
|
-
if detail:
|
|
2600
|
-
lines.append("wifi: " + ", ".join(detail))
|
|
2601
|
-
cellular = network.get("cellular") or {}
|
|
2602
|
-
if cellular:
|
|
2603
|
-
detail = ["%s=%s" % (key, cellular[key])
|
|
2604
|
-
for key in ("operator", "network_type", "signal_dbm", "level",
|
|
2605
|
-
"roaming", "data_state")
|
|
2606
|
-
if cellular.get(key) not in (None, "")]
|
|
2607
|
-
if detail:
|
|
2608
|
-
lines.append("cellular: " + ", ".join(detail))
|
|
2609
|
-
if cellular.get("cellinfo_reason"):
|
|
2610
|
-
lines.append("cellular: %s" % cellular["cellinfo_reason"])
|
|
2611
|
-
facts("locale", ("language", "region", "encoding"))
|
|
2612
|
-
facts("time", ("iso_local", "utc_offset", "timezone", "abbreviation"))
|
|
2613
|
-
hardware = block("hardware")
|
|
2614
|
-
if hardware and hardware.get("available") is not False:
|
|
2615
|
-
cpu = hardware.get("cpu") or {}
|
|
2616
|
-
if cpu:
|
|
2617
|
-
shown = ["%s=%s" % (key, cpu[key])
|
|
2618
|
-
for key in ("model", "cores", "freq_mhz_max", "temp_c", "load")
|
|
2619
|
-
if cpu.get(key) not in (None, "")]
|
|
2620
|
-
if shown:
|
|
2621
|
-
lines.append("cpu: " + ", ".join(shown))
|
|
2622
|
-
memory = hardware.get("memory") or {}
|
|
2623
|
-
if memory:
|
|
2624
|
-
shown = ["%s=%s" % (key, memory[key])
|
|
2625
|
-
for key in ("total_mb", "used_mb", "free_mb", "used_pct", "swap_total_mb")
|
|
2626
|
-
if memory.get(key) not in (None, "")]
|
|
2627
|
-
if shown:
|
|
2628
|
-
lines.append("memory: " + ", ".join(shown))
|
|
2629
|
-
thermal = hardware.get("thermal") or []
|
|
2630
|
-
if thermal:
|
|
2631
|
-
lines.append("thermal: " + ", ".join(
|
|
2632
|
-
"%s=%s%s" % (item.get("name"), item.get("value"),
|
|
2633
|
-
item.get("unit") or "") for item in thermal[:6]
|
|
2634
|
-
if isinstance(item, dict)))
|
|
2635
|
-
storage = block("storage")
|
|
2636
|
-
if storage:
|
|
2637
|
-
if storage.get("available") is False:
|
|
2638
|
-
facts("storage", ())
|
|
2639
|
-
else:
|
|
2640
|
-
shown = ["%s=%s" % (key, storage[key])
|
|
2641
|
-
for key in ("root", "total_gb", "free_gb", "used_pct", "shared",
|
|
2642
|
-
"capture_dir")
|
|
2643
|
-
if storage.get(key) not in (None, "")]
|
|
2644
|
-
if shown:
|
|
2645
|
-
lines.append("storage: " + ", ".join(shown))
|
|
2646
|
-
if storage.get("shared_fix"):
|
|
2647
|
-
lines.append("storage: %s" % storage["shared_fix"])
|
|
2648
|
-
for name, fields in (("cameras", ("count",)), ("microphones", ("count",)),
|
|
2649
|
-
("sensors", ("count", "sampling"))):
|
|
2650
|
-
value = block(name)
|
|
2651
|
-
if value is None:
|
|
2652
|
-
continue
|
|
2653
|
-
if value.get("available") is False:
|
|
2654
|
-
facts(name, ())
|
|
2655
|
-
continue
|
|
2656
|
-
shown = ["%s=%s" % (key, value[key]) for key in fields if value.get(key) not in (None, "")]
|
|
2657
|
-
items = value.get(name) or value.get("sensors") or []
|
|
2658
|
-
names = []
|
|
2659
|
-
for item in items[:4] if isinstance(items, list) else []:
|
|
2660
|
-
if isinstance(item, dict):
|
|
2661
|
-
label = item.get("name") or item.get("facing") or item.get("id")
|
|
2662
|
-
if label not in (None, ""):
|
|
2663
|
-
names.append(str(label))
|
|
2664
|
-
elif isinstance(item, str):
|
|
2665
|
-
names.append(item)
|
|
2666
|
-
text = ", ".join(shown)
|
|
2667
|
-
if names:
|
|
2668
|
-
text = (text + " (" + ", ".join(names) + ")") if text else ", ".join(names)
|
|
2669
|
-
if text:
|
|
2670
|
-
lines.append("%s: %s" % (name, text))
|
|
2671
|
-
capabilities = block("capabilities")
|
|
2672
|
-
if capabilities:
|
|
2673
|
-
api = capabilities.get("termux_api") or capabilities.get("api") or {}
|
|
2674
|
-
if api:
|
|
2675
|
-
lines.append("termux-api: installed=%s" % api.get("installed"))
|
|
2676
|
-
if api.get("missing"):
|
|
2677
|
-
lines.append("termux-api missing: %s" % ", ".join(api["missing"][:8]))
|
|
2678
|
-
if capabilities.get("app_reachable") is False:
|
|
2679
|
-
lines.append("termux-api app: not answering - %s"
|
|
2680
|
-
% (capabilities.get("app_fix") or "open the Termux:API app once"))
|
|
2681
|
-
permissions = capabilities.get("permissions") or {}
|
|
2682
|
-
denied = sorted(name for name, state in permissions.items() if state == "denied")
|
|
2683
|
-
if denied:
|
|
2684
|
-
lines.append("permissions denied: %s" % ", ".join(denied))
|
|
2685
|
-
return lines
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
def _format_device_result(name, res):
|
|
2689
|
-
"""Render one ``device_*`` answer for the model.
|
|
2690
|
-
|
|
2691
|
-
"Not available on this node" is deliberately NOT an error: on a Windows or
|
|
2692
|
-
Linux laptop a phone-only action is the normal answer, the node reports it as
|
|
2693
|
-
``available: false`` with a reason (and often a fix), and reading that as a
|
|
2694
|
-
crash would tell the model the host is broken when it is merely not a phone.
|
|
2695
|
-
"""
|
|
2696
|
-
if not isinstance(res, dict):
|
|
2697
|
-
return json.dumps(res, ensure_ascii=False, indent=2, default=str)
|
|
2698
|
-
if "error" in res:
|
|
2699
|
-
# The relay already turns a real failure into isError; this keeps the text
|
|
2700
|
-
# consistent with the other "[Error] ..." answers if this path is reached.
|
|
2701
|
-
return "[Error] " + str(res.get("error"))
|
|
2702
|
-
if res.get("available") is False:
|
|
2703
|
-
lines = ["%s: not available on this node - %s"
|
|
2704
|
-
% (name, res.get("reason") or "this device does not expose it")]
|
|
2705
|
-
if res.get("fix"):
|
|
2706
|
-
lines.append("Fix: %s" % res.get("fix"))
|
|
2707
|
-
return "\n".join(lines)
|
|
2708
|
-
text = json.dumps(res, ensure_ascii=False, indent=2, default=str)
|
|
2709
|
-
if len(text) > DEVICE_REPORT_LIMIT:
|
|
2710
|
-
text = text[:DEVICE_REPORT_LIMIT] + (
|
|
2711
|
-
"\n... [device report truncated; call the tool again with section=<name> for one block]")
|
|
2712
|
-
return text
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
def _share_response(status: int, message: str, extra=None) -> Response:
|
|
2716
|
-
headers = {"content-type": "text/plain; charset=utf-8", "cache-control": "no-store"}
|
|
2717
|
-
if extra:
|
|
2718
|
-
headers.update(extra)
|
|
2719
|
-
return Response(message + "\n", status_code=status, headers=headers)
|
|
2720
|
-
|
|
2721
|
-
async def public_share_endpoint(request: Request) -> Response:
|
|
2722
|
-
"""Relay one public share request to the node that owns it."""
|
|
2723
|
-
user = (request.path_params.get("user") or "").strip().lower()
|
|
2724
|
-
rest = (request.path_params.get("rest") or "").strip("/")
|
|
2725
|
-
if not user or not rest:
|
|
2726
|
-
return _share_response(404, "not found")
|
|
2727
|
-
if user not in load_registry():
|
|
2728
|
-
return _share_response(404, "not found")
|
|
2729
|
-
|
|
2730
|
-
slug, _, sub_path = rest.partition("/")
|
|
2731
|
-
if not SHARE_SLUG_RE.match(slug):
|
|
2732
|
-
return _share_response(404, "not found")
|
|
2733
|
-
|
|
2734
|
-
method = request.method.upper()
|
|
2735
|
-
if method not in SHARE_METHODS:
|
|
2736
|
-
return _share_response(405, "method not allowed", {"allow": "GET, HEAD"})
|
|
2737
|
-
if user not in active_tunnels:
|
|
2738
|
-
return _share_response(503, "node offline: the mesh agent on '%s' is not connected" % user)
|
|
2739
|
-
|
|
2740
|
-
result = await call_remote_tool(user, "_http_share", {
|
|
2741
|
-
"slug": slug,
|
|
2742
|
-
"path": "/" + sub_path,
|
|
2743
|
-
"method": method,
|
|
2744
|
-
"query": request.url.query or "",
|
|
2745
|
-
})
|
|
2746
|
-
if not isinstance(result, dict) or "status" not in result:
|
|
2747
|
-
logger.warning("share relay for '%s' returned an invalid response: %r", user, result)
|
|
2748
|
-
return _share_response(502, "bad gateway")
|
|
2749
|
-
try:
|
|
2750
|
-
status = int(result["status"])
|
|
2751
|
-
body = base64.b64decode(result.get("body_b64") or "")
|
|
2752
|
-
except Exception:
|
|
2753
|
-
return _share_response(502, "bad gateway")
|
|
2754
|
-
if not 200 <= status <= 599:
|
|
2755
|
-
# 1xx is not a final response: h11 rejects it and the client gets no
|
|
2756
|
-
# response at all, so a buggy node reply becomes a clean 502 instead.
|
|
2757
|
-
return _share_response(502, "bad gateway")
|
|
2758
|
-
headers = _relay_headers(result.get("headers"))
|
|
2759
|
-
if status in (204, 304):
|
|
2760
|
-
# These statuses must not carry a body or a Content-Length.
|
|
2761
|
-
body = b""
|
|
2762
|
-
headers.pop("content-length", None)
|
|
2763
|
-
elif method == "HEAD":
|
|
2764
|
-
# Keep the node's Content-Length - it describes the resource while the
|
|
2765
|
-
# body is empty - but only a sane numeric value.
|
|
2766
|
-
if not _CONTENT_LENGTH_RE.match(headers.get("content-length", "")):
|
|
2767
|
-
headers.pop("content-length", None)
|
|
2768
|
-
else:
|
|
2769
|
-
# Never trust a declared length: a body that disagrees with
|
|
2770
|
-
# Content-Length is a protocol error (h11 aborts the response).
|
|
2771
|
-
headers["content-length"] = str(len(body))
|
|
2772
|
-
return Response(content=body, status_code=status, headers=headers, media_type=None)
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
routes = [
|
|
2776
|
-
Route("/", health, methods=["GET", "HEAD", "OPTIONS"]),
|
|
2777
|
-
Route("/health", health, methods=["GET", "HEAD", "OPTIONS"]),
|
|
2778
|
-
Route("/health-mesh", health, methods=["GET", "HEAD", "OPTIONS"]),
|
|
2779
|
-
Route("/api/register", api_register, methods=["POST", "OPTIONS"]),
|
|
2780
|
-
Route("/.well-known/oauth-protected-resource", oauth_discovery, methods=["GET", "HEAD", "OPTIONS"]),
|
|
2781
|
-
Route("/.well-known/oauth-protected-resource/{path:path}", oauth_discovery, methods=["GET", "HEAD", "OPTIONS"]),
|
|
2782
|
-
Route("/sse", mcp_unified_endpoint, methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"]),
|
|
2783
|
-
Route("/mcp", mcp_unified_endpoint, methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"]),
|
|
2784
|
-
Route("/messages", mcp_unified_endpoint, methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"]),
|
|
2785
|
-
WebSocketRoute("/ws/tunnel", ws_tunnel_endpoint),
|
|
2786
|
-
# Public file shares. MUST stay last: it is a catch-all for two-segment
|
|
2787
|
-
# paths and must never shadow /sse, /mcp, /health, /api/register or the
|
|
2788
|
-
# WebSocket tunnel above.
|
|
2789
|
-
Route("/{user}/{rest:path}", public_share_endpoint, methods=["GET", "HEAD", "OPTIONS"]),
|
|
2790
|
-
]
|
|
2791
|
-
|
|
2792
|
-
middleware = [
|
|
2793
|
-
Middleware(
|
|
2794
|
-
CORSMiddleware,
|
|
2795
|
-
allow_origins=["*"],
|
|
2796
|
-
allow_methods=["*"],
|
|
2797
|
-
allow_headers=["*"],
|
|
2798
|
-
expose_headers=["mcp-session-id", "mcp-protocol-version"],
|
|
2799
|
-
)
|
|
2800
|
-
]
|
|
2801
|
-
|
|
2802
|
-
async def tunnel_watchdog():
|
|
2803
|
-
"""Background self-healing loop.
|
|
2804
|
-
|
|
2805
|
-
A websocket can die without a clean close (NAT timeout, host sleep). The entry
|
|
2806
|
-
then stays in active_tunnels, so /health reports the node online while every
|
|
2807
|
-
call hangs until the 28 s timeout. This loop removes such entries, prunes SSE
|
|
2808
|
-
queues nobody consumes and keeps the state truthful.
|
|
2809
|
-
"""
|
|
2810
|
-
logger.info("Self-heal watchdog started (30s interval)")
|
|
2811
|
-
while True:
|
|
2812
|
-
try:
|
|
2813
|
-
await asyncio.sleep(30)
|
|
2814
|
-
for user, tunnel in list(active_tunnels.items()):
|
|
2815
|
-
ws = tunnel.get("ws")
|
|
2816
|
-
state = getattr(getattr(ws, "client_state", None), "name", "UNKNOWN")
|
|
2817
|
-
if ws is None or state not in ("CONNECTED",):
|
|
2818
|
-
logger.warning(f"Watchdog: removing stale tunnel for '{user}' (state={state})")
|
|
2819
|
-
for fut in list(tunnel.get("pending", {}).values()):
|
|
2820
|
-
if not fut.done():
|
|
2821
|
-
fut.set_exception(ConnectionResetError("Tunnel was stale"))
|
|
2822
|
-
active_tunnels.pop(user, None)
|
|
2823
|
-
# The stale path is a real disconnect: without this event the
|
|
2824
|
-
# history would show a connect with no matching disconnect
|
|
2825
|
-
# after a host reboot or a NAT timeout.
|
|
2826
|
-
record_tunnel_event("disconnect", user, f"stale tunnel (state={state})")
|
|
2827
|
-
# drop subscriber queues that no stream is reading any more. The
|
|
2828
|
-
# per-user container is an ordered list, so the surviving queues keep
|
|
2829
|
-
# their order (oldest first) for the next cap eviction.
|
|
2830
|
-
for user, queues in list(active_sse_subscribers.items()):
|
|
2831
|
-
alive = [q for q in queues if not q.empty() or q in active_sse_sessions.values()]
|
|
2832
|
-
if alive != queues:
|
|
2833
|
-
active_sse_subscribers[user] = alive
|
|
2834
|
-
if not alive:
|
|
2835
|
-
active_sse_subscribers.pop(user, None)
|
|
2836
|
-
except asyncio.CancelledError:
|
|
2837
|
-
raise
|
|
2838
|
-
except Exception as exc: # never let the watchdog die
|
|
2839
|
-
logger.warning(f"Watchdog error: {exc}")
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
@asynccontextmanager
|
|
2843
|
-
async def lifespan(_app):
|
|
2844
|
-
"""Start the self-healing watchdog with the app (modern Starlette API)."""
|
|
2845
|
-
task = asyncio.create_task(tunnel_watchdog())
|
|
2846
|
-
try:
|
|
2847
|
-
yield
|
|
2848
|
-
finally:
|
|
2849
|
-
task.cancel()
|
|
2850
|
-
|
|
2851
|
-
app = Starlette(debug=False, routes=routes, middleware=middleware, lifespan=lifespan)
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
if __name__ == "__main__":
|
|
2857
|
-
# ws_max_size matters for public file shares: the node sends the base64
|
|
2858
|
-
# payload through the tunnel as ONE websocket message, so uvicorn's 16 MiB
|
|
2859
|
-
# default silently drops anything above ~12 MiB. 128 MiB carries the node's
|
|
2860
|
-
# maximum share size (64 MiB -> ~85 MiB on the wire) with headroom.
|
|
2861
|
-
uvicorn.run(app, host="127.0.0.1", port=8096, log_level="info",
|
|
2862
|
-
timeout_graceful_shutdown=2, ws_max_size=128 * 1024 * 1024)
|