gemini-computer-use 0.2.3 → 0.4.1

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.
Files changed (41) hide show
  1. package/README.md +935 -148
  2. package/bin/cli.js +112 -17
  3. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  4. package/core/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/core/__pycache__/code_cache.cpython-313.pyc +0 -0
  6. package/core/__pycache__/mcp_tools.cpython-313.pyc +0 -0
  7. package/core/__pycache__/schemas.cpython-313.pyc +0 -0
  8. package/core/__pycache__/server.cpython-313.pyc +0 -0
  9. package/core/__pycache__/vitals.cpython-313.pyc +0 -0
  10. package/core/__pycache__/web_share.cpython-313.pyc +0 -0
  11. package/core/agent.py +688 -27
  12. package/core/agent_harness.py +9 -2
  13. package/core/code_cache.py +15 -3
  14. package/core/device.py +2400 -0
  15. package/core/domain.py +303 -0
  16. package/core/mcp_tools.py +1949 -110
  17. package/core/policies.py +35 -5
  18. package/core/server.py +78 -32
  19. package/core/termux.py +916 -0
  20. package/core/updater.py +1713 -0
  21. package/core/version.py +31 -0
  22. package/core/vitals.py +66 -2
  23. package/core/web_share.py +814 -0
  24. package/gateway.py +1469 -98
  25. package/install-gui.cmd +40 -0
  26. package/install-gui.ps1 +1882 -0
  27. package/install-gui.strings.json +211 -0
  28. package/install.ps1 +960 -92
  29. package/install.sh +1214 -81
  30. package/ops/doctor.ps1 +735 -0
  31. package/ops/mesh-domain.sh +86 -0
  32. package/ops/nginx/README.md +77 -0
  33. package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
  34. package/ops/nginx/mesh-domain.vhost.template +58 -0
  35. package/ops/nginx/render-domain.sh +123 -0
  36. package/ops/update.ps1 +373 -0
  37. package/ops/windows/README.md +298 -0
  38. package/ops/windows/agent-watchdog.ps1 +575 -0
  39. package/ops/windows/run-hidden.vbs +64 -0
  40. package/package.json +6 -2
  41. package/skills/orchestrator.md +12 -0
package/gateway.py CHANGED
@@ -1,4 +1,6 @@
1
1
  import asyncio
2
+ import base64
3
+ from collections import deque
2
4
  from contextlib import asynccontextmanager
3
5
  import datetime
4
6
  import hmac
@@ -26,11 +28,81 @@ logger = logging.getLogger("gateway")
26
28
 
27
29
  REGISTRY_PATH = "/etc/antigravity-mesh/registry.json"
28
30
  active_tunnels = {} # user -> {"ws": WebSocket, "pending": {req_id: Future}}
29
- active_sse_subscribers = {} # user -> set of asyncio.Queue
30
- active_sse_sessions = {} # session_id -> asyncio.Queue
31
+ # 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 = {}
31
42
  active_sessions = {} # session_id -> user, issued once per initialize
32
43
  latest_session_by_user = {} # user -> most recently issued session id
33
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
+
34
106
  # Negotiated protocol version per user. The value must be identical in the
35
107
  # initialize result AND in the mcp-protocol-version header of every response: a
36
108
  # client that asked for 2025-11-25 and reads 2024-11-05 back in the header can
@@ -69,12 +141,32 @@ BROADCAST_HTTP_RESPONSES = os.environ.get(
69
141
  # SSE https://<shared-domain>/sse?user=<node>&token=<token>
70
142
  # HTTP https://<shared-domain>/mcp?user=<node>&token=<token>
71
143
  # Tunnel wss://<shared-domain>/ws/tunnel?user=<node>&token=<token>
72
- # Override the shared domain with MESH_PUBLIC_URL (or AGY_PUBLIC_BASE_URL).
73
- PUBLIC_BASE_URL = (
74
- os.environ.get("MESH_PUBLIC_URL")
75
- or os.environ.get("AGY_PUBLIC_BASE_URL")
76
- or "https://smart-server.online"
77
- ).strip().rstrip("/")
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
+ )
78
170
  PUBLIC_HOST = urlparse(PUBLIC_BASE_URL).netloc or PUBLIC_BASE_URL
79
171
 
80
172
  # Legacy per-device subdomain resolution. Kept only so URLs handed out before the
@@ -281,7 +373,12 @@ async def call_remote_tool(user: str, name: str, args: dict) -> dict:
281
373
  # The agent reconnects within ~2s of a dropped link (VPN flap, network
282
374
  # change, service restart), so give it a moment instead of reporting the
283
375
  # node as offline immediately. A missing agent is then a rare answer.
284
- for _ in range(16): # up to ~8 seconds
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
285
382
  await asyncio.sleep(0.5)
286
383
  tunnel = active_tunnels.get(user)
287
384
  if tunnel and tunnel.get("ws"):
@@ -290,9 +387,9 @@ async def call_remote_tool(user: str, name: str, args: dict) -> dict:
290
387
  return {
291
388
  "exit_code": 1,
292
389
  "stdout": "",
293
- "stderr": (f"[Mesh Gateway] Node '{user}' did not come back within 8 seconds - the agent "
294
- f"on the host is not connected. Start/restart the mesh agent there, then retry; "
295
- f"this is a transport problem, not a missing file or permission.")
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.")
296
393
  }
297
394
  req_id = str(uuid.uuid4())
298
395
  fut = asyncio.get_running_loop().create_future()
@@ -317,7 +414,7 @@ async def call_remote_tool(user: str, name: str, args: dict) -> dict:
317
414
  # Wait for the agent to come back and replay the call once instead of
318
415
  # failing the client's request.
319
416
  logger.warning(f"Tunnel call to '{user}' failed ({e}); waiting for reconnect and retrying once")
320
- for _ in range(20): # up to ~10 seconds
417
+ for _ in range(4): # up to ~2 seconds
321
418
  await asyncio.sleep(0.5)
322
419
  fresh = active_tunnels.get(user)
323
420
  if fresh and fresh.get("ws") and fresh.get("ws") is not tunnel.get("ws"):
@@ -372,8 +469,16 @@ async def health(request: Request):
372
469
  "active_tunnels_count": len(active_tunnels),
373
470
  # Diagnostics: an abandoned SSE stream keeps a queue alive, and a client
374
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.
375
475
  "open_sse_streams": sum(len(q) for q in active_sse_subscribers.values()),
376
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),
377
482
  }, headers={"Access-Control-Allow-Origin": "*"})
378
483
 
379
484
  async def api_register(request: Request):
@@ -428,7 +533,10 @@ async def api_register(request: Request):
428
533
  "sse_url": public_url("/sse", u, token),
429
534
  "tunnel_url": f"wss://{PUBLIC_HOST}/ws/tunnel?user={u}&token={token}",
430
535
  "reused": True,
431
- "instructions": f"Antigravity Mesh node '{user}'. Direct access to the real remote host over a reverse tunnel. "
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. "
432
540
  f"Do not guess paths or outputs: use list_dir/glob_find/grep_search/read_file/bash_exec/"
433
541
  f"system_vitals. Use run_job for slow commands; page output with max_chars+cursor."
434
542
  })
@@ -573,36 +681,82 @@ async def sse_endpoint(request: Request):
573
681
  # the stream over it - the stream it was reading tool results from.
574
682
  announce_endpoint = announce_endpoint_for_path(request.url.path)
575
683
  queue = asyncio.Queue()
576
- if user not in active_sse_subscribers:
577
- active_sse_subscribers[user] = set()
578
- active_sse_subscribers[user].add(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)
579
702
  # stored under the per-connection key, not the shared client-visible id
580
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)
581
707
  logger.info(
582
708
  f"SSE stream opened for '{user}' (stream_key={stream_key}, "
583
709
  f"session={session_id[:12]}…, path={request.url.path}, "
584
- f"open_now={len(active_sse_subscribers[user])})"
710
+ f"open_now={len(queues)}"
711
+ + (f", evicted={evicted} over the cap of {MAX_SSE_PER_USER}" if evicted else "")
712
+ + ")"
585
713
  )
586
714
 
587
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()
588
723
  try:
589
724
  if announce_endpoint:
590
725
  yield f"event: endpoint\ndata: /messages?user={user}&token={token}\n\n"
591
726
  while True:
592
727
  try:
593
- msg = await asyncio.wait_for(queue.get(), timeout=15.0)
594
- if msg is None:
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}")
595
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:
596
748
  yield f"event: message\ndata: {msg}\n\n"
597
- except asyncio.TimeoutError:
598
- yield ": keepalive\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()
599
754
  except asyncio.CancelledError:
600
755
  logger.info(f"SSE stream cancelled for '{user}' (stream_key={stream_key})")
601
756
  raise
602
757
  finally:
603
758
  active_sse_sessions.pop(stream_key, None)
604
- if user in active_sse_subscribers and queue in active_sse_subscribers[user]:
605
- active_sse_subscribers[user].remove(queue)
759
+ forget_sse_queue(user, queue)
606
760
  logger.info(f"SSE stream closed for '{user}' (stream_key={stream_key}, "
607
761
  f"open_now={len(active_sse_subscribers.get(user, ()))})")
608
762
 
@@ -700,6 +854,20 @@ async def messages_endpoint(request: Request):
700
854
  else:
701
855
  negotiated = "2024-11-05"
702
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}'")
703
871
  session_id = issue_session_id(user)
704
872
  issued_session_id = session_id
705
873
  logger.info(f"initialize: client requested protocolVersion={client_version!r} "
@@ -731,7 +899,11 @@ async def messages_endpoint(request: Request):
731
899
  f"REACHABILITY: every tool result you receive in this conversation was produced ON the "
732
900
  f"node, so the node IS online. After any successful tool result, never tell the user the "
733
901
  f"host is unreachable or that the agent did not answer. If a call fails, report its exact "
734
- f"error text instead of guessing that the host is down."
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."
735
907
  )
736
908
  )
737
909
  )
@@ -750,8 +922,10 @@ async def messages_endpoint(request: Request):
750
922
  elif method == "ping":
751
923
  resp["result"] = {}
752
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.
753
927
  resp["result"] = {
754
- "tools": [
928
+ "tools": annotate_tools([
755
929
  {
756
930
  "name": "list_dir",
757
931
  "description": (
@@ -787,8 +961,13 @@ async def messages_endpoint(request: Request):
787
961
  "name": "bash_exec",
788
962
  "description": (
789
963
  f"Execute a shell command directly on {user}'s host machine. "
790
- "MANDATORY: Always use this tool to inspect git status, run builds, check processes, or execute tasks. "
791
- "NEVER guess command outputs."
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."
792
971
  ),
793
972
  "inputSchema": {
794
973
  "type": "object",
@@ -801,12 +980,288 @@ async def messages_endpoint(request: Request):
801
980
  "required": ["command"]
802
981
  }
803
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
+ },
804
1014
  {
805
1015
  "name": "mesh_status",
806
1016
  "description": ("Confirm this node is reachable. Call this FIRST if you believe the "
807
1017
  "host is offline: it returns live evidence from the host."),
808
1018
  "inputSchema": {"type": "object", "properties": {}, "required": []}
809
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
+ },
810
1265
  {
811
1266
  "name": "system_info",
812
1267
  "description": ("One-call host summary: OS, desktop, user, home, disks, memory, load, "
@@ -827,7 +1282,11 @@ async def messages_endpoint(request: Request):
827
1282
  {
828
1283
  "name": "write_file",
829
1284
  "description": (
830
- "Write a file atomically (creates parent dirs)."
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."
831
1290
  ),
832
1291
  "inputSchema": {
833
1292
  "type": "object",
@@ -843,7 +1302,10 @@ async def messages_endpoint(request: Request):
843
1302
  {
844
1303
  "name": "edit_file",
845
1304
  "description": (
846
- "Replace an exact string in a file; old_string must be unique."
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."
847
1309
  ),
848
1310
  "inputSchema": {
849
1311
  "type": "object",
@@ -857,6 +1319,27 @@ async def messages_endpoint(request: Request):
857
1319
  "required": ["path", "old_string", "new_string"]
858
1320
  }
859
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
+ },
860
1343
  {
861
1344
  "name": "grep_search",
862
1345
  "description": (
@@ -893,7 +1376,9 @@ async def messages_endpoint(request: Request):
893
1376
  {
894
1377
  "name": "run_job",
895
1378
  "description": (
896
- "Start a long command in the background; returns job_id."
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)."
897
1382
  ),
898
1383
  "inputSchema": {
899
1384
  "type": "object",
@@ -939,7 +1424,7 @@ async def messages_endpoint(request: Request):
939
1424
  "limit": {"type": "integer", "description": "How many jobs to return (1-50, default 20)"}
940
1425
  }, "required": []}
941
1426
  }
942
- ]
1427
+ ] + SHARE_TOOL_SPECS)
943
1428
  }
944
1429
  elif method == "tools/call":
945
1430
  name = params.get("name")
@@ -956,10 +1441,33 @@ async def messages_endpoint(request: Request):
956
1441
  else:
957
1442
  parts = ["[NODE REACHABLE] This answer was produced on the host itself - the mesh "
958
1443
  "agent and the tunnel are working. Do not claim the node is offline."]
959
- for key in ("hostname", "agent_pid", "checked_at", "host_uptime_human", "workspace"):
1444
+ for key in ("hostname", "agent_pid", "checked_at", "host_uptime_human",
1445
+ "workspace", "node_version"):
960
1446
  if res.get(key) is not None:
961
1447
  parts.append(f"{key}: {res.get(key)}")
962
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)
963
1471
  elif name == "system_info":
964
1472
  res = await call_remote_tool(user, name, args)
965
1473
  err = remote_tool_error(res)
@@ -968,14 +1476,19 @@ async def messages_endpoint(request: Request):
968
1476
  content_text = f"[Error] {err}"
969
1477
  else:
970
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.
971
1482
  for key in ("hostname", "os", "kernel", "user", "home", "desktop",
972
- "session_type", "wallpaper", "wallpaper_exists"):
1483
+ "session_type", "command_shell", "scenario", "device_class",
1484
+ "wallpaper", "wallpaper_exists"):
973
1485
  value = res.get(key)
974
1486
  if value not in (None, ""):
975
1487
  parts.append("%s: %s" % (key, value))
976
1488
  for key in ("memory", "load", "disks", "top_processes"):
977
1489
  if res.get(key):
978
1490
  parts.append("\n[%s]\n%s" % (key, res.get(key)))
1491
+ parts.extend(_device_report_lines(res))
979
1492
  content_text = "\n".join(parts) if parts else json.dumps(res, ensure_ascii=False, indent=2)
980
1493
  elif name == "system_vitals":
981
1494
  res = await call_remote_tool(user, name, args)
@@ -985,56 +1498,51 @@ async def messages_endpoint(request: Request):
985
1498
  else:
986
1499
  content_text = json.dumps(res, ensure_ascii=False, indent=2)
987
1500
  elif name in ("bash_exec", "list_dir", "read_file"):
988
- # Gateway-level guard: call_remote_tool gives up after 28s, so never forward
989
- # a bash timeout longer than 25s (node may default to 25 anyway).
1501
+ refusal = ""
990
1502
  if name == "bash_exec":
991
- timeout_sec = args.get("timeout_sec")
992
- if isinstance(timeout_sec, (int, float)) and timeout_sec > 25:
993
- args = dict(args)
994
- args["timeout_sec"] = 25
995
- res = await call_remote_tool(user, name, args)
996
- if "error" in res:
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.
997
1525
  is_error = True
998
- content_text = f"[Execution Error]: {res.get('error')}"
1526
+ content_text = path_confirmation_message(args.get("path", ""), name)
1527
+ res = None
999
1528
  else:
1000
- exit_code = res.get("exit_code", 0)
1001
- # Never strip: a paginated chunk must survive byte-exact so the
1002
- # client can rebuild the output by following next_cursor.
1003
- stdout = res.get("stdout", "")
1004
- stderr = res.get("stderr", "")
1005
- if exit_code != 0:
1529
+ res = await call_remote_tool(user, name, args)
1530
+ if res is not None:
1531
+ err = remote_tool_error(res)
1532
+ if err:
1006
1533
  is_error = True
1007
- # A non-zero exit code is the command's own result, not a
1008
- # transport failure: the command RAN on the host. Without this
1009
- # wording a model reads isError=true as "the node is
1010
- # unreachable" and stops looking for the real output.
1011
- parts = [
1012
- f"[Exit code: {exit_code}] The command ran on the host and the node "
1013
- f"is reachable; {exit_code} is the command's own exit status."
1014
- ]
1015
- if stderr.strip():
1016
- parts.append(stderr)
1017
- if stdout.strip():
1018
- parts.append(stdout)
1019
- content_text = "\n".join(parts) if parts else f"[Exit code: {exit_code}]"
1534
+ content_text = f"[Error] {err}"
1020
1535
  else:
1021
- parts = []
1022
- if stdout:
1023
- parts.append(stdout)
1024
- if stderr:
1025
- parts.append(f"[STDERR]\n{stderr}")
1026
- content_text = "\n\n".join(parts) if parts else "(Command executed successfully with no output)"
1027
- # Surface node-side pagination/spooling so the model knows there is more.
1028
- if isinstance(res, dict):
1029
- if res.get("next_cursor") is not None:
1030
- content_text += (
1031
- f"\n\n[output continues on the node: call {name} again with "
1032
- f"cursor={res.get('next_cursor')} (optionally a larger max_chars)]"
1033
- )
1034
- if res.get("saved_to"):
1035
- content_text += f"\n\n[full output saved on the node at {res['saved_to']}]"
1036
- elif name in ("write_file", "edit_file"):
1037
- res = await call_remote_tool(user, name, args)
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)
1038
1546
  err = remote_tool_error(res)
1039
1547
  if err:
1040
1548
  is_error = True
@@ -1042,10 +1550,10 @@ async def messages_endpoint(request: Request):
1042
1550
  else:
1043
1551
  path = res.get("path", args.get("path", ""))
1044
1552
  sha = res.get("sha256", "")
1045
- if name == "write_file":
1046
- content_text = f"OK: wrote {res.get('bytes', 0)} bytes to {path} (sha256={sha})"
1047
- else:
1048
- content_text = f"OK: edited {path} ({res.get('replacements', 0)} replacement(s), sha256={sha})"
1553
+ content_text = (
1554
+ f"OK: wrote {res.get('bytes', 0)} bytes to {path} (sha256={sha}) "
1555
+ f"(confirmed system write)"
1556
+ )
1049
1557
  elif name == "grep_search":
1050
1558
  res = await call_remote_tool(user, name, args)
1051
1559
  err = remote_tool_error(res)
@@ -1077,18 +1585,51 @@ async def messages_endpoint(request: Request):
1077
1585
  if res.get("truncated"):
1078
1586
  content_text += f"\n\n(truncated: showing first {len(files)} paths)"
1079
1587
  elif name == "run_job":
1080
- res = await call_remote_tool(user, name, args)
1081
- err = remote_tool_error(res)
1082
- if err:
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.
1083
1592
  is_error = True
1084
- content_text = f"[Error] {err}"
1593
+ content_text = command_confirmation_message(args.get("command", ""), kind, "run_job")
1085
1594
  else:
1086
- job_id = res.get("job_id", "")
1087
- content_text = (
1088
- f"Started job {job_id} (pid {res.get('pid')})\n"
1089
- f"command: {res.get('command', args.get('command', ''))}\n"
1090
- f"Use job_output(job_id=\"{job_id}\") to poll progress; use job_kill(job_id=\"{job_id}\") to stop it."
1091
- )
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)
1092
1633
  elif name == "job_output":
1093
1634
  # Gateway-level guard: call_remote_tool gives up after 28s, keep wait below it.
1094
1635
  wait_ms = args.get("wait_ms")
@@ -1145,6 +1686,57 @@ async def messages_endpoint(request: Request):
1145
1686
  )
1146
1687
  )
1147
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)
1148
1740
  else:
1149
1741
  resp["error"] = {"code": -32601, "message": f"Unknown tool: {name}"}
1150
1742
  broadcast_sse(user, resp)
@@ -1301,14 +1893,19 @@ async def mcp_unified_endpoint(request: Request):
1301
1893
  # that another in-flight request is still reading.
1302
1894
  active_sessions.pop(session_id, None)
1303
1895
 
1304
- # Clean up session queue if active
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.
1305
1901
  if session_id in active_sse_sessions:
1306
1902
  q = active_sse_sessions.pop(session_id, None)
1307
- if q:
1903
+ if q is not None:
1308
1904
  try:
1309
1905
  q.put_nowait(None)
1310
1906
  except Exception:
1311
1907
  pass
1908
+ forget_sse_queue(user, q)
1312
1909
 
1313
1910
  return Response(
1314
1911
  status_code=204,
@@ -1397,7 +1994,11 @@ async def ws_tunnel_endpoint(websocket: WebSocket):
1397
1994
  logger.info(f"Agent tunnel connected for user '{user}'")
1398
1995
  tunnel_data = {"ws": websocket, "pending": {}}
1399
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")
1400
2000
 
2001
+ disconnect_reason = "tunnel closed"
1401
2002
  try:
1402
2003
  while True:
1403
2004
  raw = await websocket.receive_text()
@@ -1411,10 +2012,765 @@ async def ws_tunnel_endpoint(websocket: WebSocket):
1411
2012
  if not fut.done():
1412
2013
  fut.set_result(msg)
1413
2014
  except WebSocketDisconnect:
2015
+ disconnect_reason = "websocket disconnected"
1414
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
1415
2021
  finally:
1416
2022
  if active_tunnels.get(user, {}).get("ws") == websocket:
1417
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
+
1418
2774
 
1419
2775
  routes = [
1420
2776
  Route("/", health, methods=["GET", "HEAD", "OPTIONS"]),
@@ -1427,6 +2783,10 @@ routes = [
1427
2783
  Route("/mcp", mcp_unified_endpoint, methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"]),
1428
2784
  Route("/messages", mcp_unified_endpoint, methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"]),
1429
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"]),
1430
2790
  ]
1431
2791
 
1432
2792
  middleware = [
@@ -1460,9 +2820,15 @@ async def tunnel_watchdog():
1460
2820
  if not fut.done():
1461
2821
  fut.set_exception(ConnectionResetError("Tunnel was stale"))
1462
2822
  active_tunnels.pop(user, None)
1463
- # drop subscriber queues that no stream is reading any more
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.
1464
2830
  for user, queues in list(active_sse_subscribers.items()):
1465
- alive = {q for q in queues if not q.empty() or q in active_sse_sessions.values()}
2831
+ alive = [q for q in queues if not q.empty() or q in active_sse_sessions.values()]
1466
2832
  if alive != queues:
1467
2833
  active_sse_subscribers[user] = alive
1468
2834
  if not alive:
@@ -1488,4 +2854,9 @@ app = Starlette(debug=False, routes=routes, middleware=middleware, lifespan=life
1488
2854
 
1489
2855
 
1490
2856
  if __name__ == "__main__":
1491
- uvicorn.run(app, host="127.0.0.1", port=8096, log_level="info", timeout_graceful_shutdown=2)
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)