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/core/agent.py CHANGED
@@ -7,27 +7,190 @@ so an unmodified gateway keeps working.
7
7
 
8
8
  Configuration (read once at import/startup)::
9
9
 
10
+ MESH_GATEWAY shared gateway domain
11
+ MESH_USER node name
12
+ MESH_TOKEN node token
13
+ MESH_CONFIG_FILE KEY=VALUE file used for any of the three above that is not
14
+ set in the environment (default:
15
+ ~/.config/antigravity-mesh/agent.env). This keeps the token
16
+ out of launchers such as the Windows Startup script. Any
17
+ other MESH_* key in that file is exported as well.
18
+ MESH_SHELL Windows command shell: "cmd" or "git-bash" (default:
19
+ PowerShell, falling back to cmd.exe when PowerShell is absent)
10
20
  MESH_WORKSPACE base directory for relative paths (default: cwd)
11
21
  MESH_READ_ONLY "1"/"true" -> mutating tools refuse to run
12
22
  MESH_WRITE_ROOTS pathsep-separated list of roots allowed for writes
23
+
24
+ MESH_UPDATE_* self-update: see core/updater.py. A running node checks for a
25
+ newer GitHub release in the background and installs it
26
+
27
+ Resilience contract:
28
+
29
+ * every link failure (DNS, TLS, reset, half-open socket after sleep) is retried
30
+ forever with capped exponential backoff plus jitter;
31
+ * tool calls run concurrently, so a slow command never delays another call or
32
+ the keepalive pings;
33
+ * only one agent per node name runs on a machine. The gateway keeps exactly one
34
+ tunnel per node and a new tunnel supersedes the old one, so two agents would
35
+ knock each other off in an endless loop and fail every in-flight call. A
36
+ second instance therefore waits for the lock instead of connecting.
13
37
  """
14
38
 
15
39
  import asyncio
40
+ import atexit
41
+ import faulthandler
16
42
  import json
17
43
  import logging
18
44
  import os
19
45
  import random
46
+ import signal
47
+ import socket
48
+ import subprocess
49
+ import sys
50
+ import threading
51
+ import time
52
+ from typing import Optional
20
53
 
21
- import websockets
54
+ try:
55
+ import websockets
56
+ except ImportError as _websockets_error: # a launcher pinned a Python without the dep
57
+ websockets = None # type: ignore[assignment]
58
+ _WEBSOCKETS_IMPORT_ERROR = _websockets_error
59
+ else:
60
+ _WEBSOCKETS_IMPORT_ERROR = None
22
61
 
23
- from core import mcp_tools
62
+ from core import domain, mcp_tools, updater
63
+ from core import version as version_module
24
64
 
25
65
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
26
66
  logger = logging.getLogger("agy-agent")
27
67
 
28
- GATEWAY_HOST = os.environ.get("MESH_GATEWAY", "smart-server.online")
29
- USER = os.environ.get("MESH_USER", "levra7")
30
- TOKEN = os.environ.get("MESH_TOKEN", "")
68
+ CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "antigravity-mesh")
69
+ DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "agent.env")
70
+
71
+ #: Diagnostics that answer "why is this node offline?" without the tunnel. The
72
+ #: watchdog (ops/windows/agent-watchdog.ps1) and ops/doctor.ps1 read these files,
73
+ #: and the incident that motivated them - a launcher pinned to the Microsoft
74
+ #: Store Python alias that left a single torn line in agent.log and kept the node
75
+ #: offline for a day - is exactly what they prevent.
76
+ HEARTBEAT_FILE = os.path.join(CONFIG_DIR, "agent.heartbeat")
77
+ FAULT_FILE = os.path.join(CONFIG_DIR, "agent.fault.log")
78
+ STOP_MARKER_FILE = os.path.join(CONFIG_DIR, "agent.stopped")
79
+ HEARTBEAT_INTERVAL = 15.0
80
+
81
+ #: Backoff cap for ordinary link failures.
82
+ MAX_BACKOFF = 15.0
83
+ #: Wait after the gateway rejects the token: retrying fast cannot help, but the
84
+ #: node must recover by itself once it is re-registered.
85
+ UNAUTHORIZED_BACKOFF = 60.0
86
+
87
+
88
+ def read_env_file(path: str) -> dict:
89
+ """Parse a KEY=VALUE file; tolerates a UTF-8 BOM (Windows PowerShell 5 writes one).
90
+
91
+ The implementation lives in :mod:`core.domain`, which reads ``domain.env`` with
92
+ the same rules; it is re-exported here so existing importers of
93
+ ``core.agent.read_env_file`` keep working and the project keeps exactly one
94
+ parser.
95
+ """
96
+ return domain.read_env_file(path)
97
+
98
+
99
+ #: Hostnames that identify no machine. Android answers "localhost" to
100
+ #: ``gethostname(2)`` for every device, and Termux has no hostname of its own, so
101
+ #: a node that trusted it would collide with every other phone: the gateway keeps
102
+ #: exactly one tunnel per name and the newer one evicts the older.
103
+ _USELESS_HOSTNAMES = frozenset({"", "localhost", "localhost.localdomain", "android"})
104
+
105
+
106
+ def android_device_name() -> str:
107
+ """The phone's model as a node name (``Pixel 7 Pro`` -> ``pixel7pro``).
108
+
109
+ ``getprop`` ships with Android itself, needs no root and is on PATH inside
110
+ Termux. A device that does not answer yields "" and the caller falls back to
111
+ the generic name - this helper never raises.
112
+ """
113
+ for prop in ("ro.product.model", "ro.product.device"):
114
+ try:
115
+ completed = subprocess.run(["getprop", prop], capture_output=True, timeout=5)
116
+ except Exception:
117
+ continue
118
+ value = (completed.stdout or b"").decode("utf-8", "replace")
119
+ safe = "".join(c for c in value.strip().lower() if c.isalnum() or c in "-_")
120
+ if safe:
121
+ return safe
122
+ return ""
123
+
124
+
125
+ def default_node_name() -> str:
126
+ """Node name used when ``MESH_USER`` is not configured: the machine's name.
127
+
128
+ This used to be a personal nickname, so every node that did not set
129
+ ``MESH_USER`` claimed that *same* name on the shared gateway - and the gateway
130
+ keeps exactly one tunnel per name, evicting the previous one. Two unrelated
131
+ users would therefore knock each other offline in an endless loop, which is
132
+ precisely the flapping the instance lock exists to prevent (and that lock is
133
+ per machine, so it cannot help across machines). The installers already
134
+ register the sanitised hostname, so the agent now agrees with them.
135
+
136
+ On a phone the hostname is worse than useless - it is the same "localhost" for
137
+ every device - so Termux uses the device model instead, exactly as the
138
+ installer registers it.
139
+ """
140
+ try:
141
+ hostname = socket.gethostname()
142
+ except Exception:
143
+ hostname = ""
144
+ safe = "".join(c for c in hostname.strip().lower() if c.isalnum() or c in "-_")
145
+ if safe in _USELESS_HOSTNAMES and domain.is_termux():
146
+ safe = android_device_name()
147
+ return safe or "node"
148
+
149
+
150
+ def configuration_error() -> Optional[str]:
151
+ """Human-readable reason this node cannot connect, or None when it is usable.
152
+
153
+ An empty token is not a transient link failure: the gateway rejects the node
154
+ on every attempt, so retrying forever only fills agent.log and leaves a
155
+ broken autostart behind. The caller reports this and stops.
156
+ """
157
+ if not TOKEN:
158
+ return ("MESH_TOKEN is empty (environment and %s). The gateway rejects a node "
159
+ "without a token, so the tunnel cannot work - re-run the installer."
160
+ % (os.environ.get("MESH_CONFIG_FILE") or DEFAULT_CONFIG_FILE))
161
+ return None
162
+
163
+
164
+ def _load_settings() -> dict:
165
+ file_values = read_env_file(os.environ.get("MESH_CONFIG_FILE") or DEFAULT_CONFIG_FILE)
166
+
167
+ def pick(key: str, default: str) -> str:
168
+ return os.environ.get(key) or file_values.get(key) or default
169
+
170
+ # Every other MESH_* key the operator put in the same file is exported too, so
171
+ # one agent.env (or one Windows Startup launcher) configures the whole node:
172
+ # MESH_SHELL for the Windows command shell, MESH_WORKSPACE, MESH_READ_ONLY,
173
+ # MESH_WRITE_ROOTS, MESH_JOBS_DIR, MESH_MAX_OUTPUT_CHARS. The real environment
174
+ # always wins over the file.
175
+ for key, value in file_values.items():
176
+ if key.startswith("MESH_") and not os.environ.get(key):
177
+ os.environ[key] = value
178
+
179
+ return {
180
+ # The host the node dials. core.domain owns the order: the configured
181
+ # public domain (MESH_PUBLIC_URL, or domain.env) wins, and the legacy
182
+ # MESH_GATEWAY - which the loop above has just exported from agent.env - is
183
+ # only consulted while nothing else names a domain.
184
+ "gateway": domain.gateway_host(),
185
+ "user": pick("MESH_USER", default_node_name()),
186
+ "token": pick("MESH_TOKEN", ""),
187
+ }
188
+
189
+
190
+ _SETTINGS = _load_settings()
191
+ GATEWAY_HOST = _SETTINGS["gateway"]
192
+ USER = _SETTINGS["user"]
193
+ TOKEN = _SETTINGS["token"]
31
194
 
32
195
 
33
196
  def _env_flag(name: str) -> bool:
@@ -45,6 +208,26 @@ def configure_from_env() -> None:
45
208
  write_roots=write_roots,
46
209
  jobs_dir=jobs_dir,
47
210
  max_output_chars=max_chars,
211
+ # Public shares: the node name in a share link must be the name this
212
+ # tunnel registers with the gateway (the gateway routes
213
+ # /<node>/<slug>/... back to that tunnel), so it is passed explicitly
214
+ # rather than left to web_share's MESH_USER fallback - which would say
215
+ # "anonymous" whenever the operator relied on the hostname default.
216
+ # ``public_url`` is not passed here: core/domain.py owns the domain.
217
+ web_dir=os.environ.get("MESH_WEB_DIR") or None,
218
+ mesh_user=USER,
219
+ max_share_bytes=os.environ.get("MESH_WEB_MAX_BYTES"),
220
+ max_shares=os.environ.get("MESH_WEB_MAX_SHARES"),
221
+ web_listing=os.environ.get("MESH_WEB_LISTING"),
222
+ # Device branch (core/device.py, core/termux.py). "auto" is the default and
223
+ # keeps the tools advertised on every platform - the phone-only ones then
224
+ # answer with a reason instead of vanishing, because the gateway's static
225
+ # tool surface must match this node's exactly.
226
+ device=os.environ.get("MESH_DEVICE") or "auto",
227
+ device_actions=os.environ.get("MESH_DEVICE_ACTIONS") or None,
228
+ capture_dir=os.environ.get("MESH_DEVICE_CAPTURE_DIR") or None,
229
+ device_pim=_env_flag("MESH_DEVICE_PIM"),
230
+ device_quick=_env_flag("MESH_DEVICE_QUICK"),
48
231
  )
49
232
 
50
233
 
@@ -53,10 +236,436 @@ def configure_from_env() -> None:
53
236
  configure_from_env()
54
237
 
55
238
 
239
+ # ---------------------------------------------------------------------------
240
+ # Runtime diagnostics: banner, fault log, heartbeat, clean-stop marker
241
+ #
242
+ # A node that never connects leaves the client with an opaque frontend error, so
243
+ # every start records what it is: which interpreter, which websockets, from which
244
+ # directory. The heartbeat makes "the agent runs but is not connected" visible to
245
+ # the watchdog, and the absence of a clean-stop marker tells it that the process
246
+ # was killed instead of shutting down.
247
+ # ---------------------------------------------------------------------------
248
+
249
+ _RUNTIME_FACTS = None
250
+ _FAULT_HANDLE = None
251
+
252
+ #: Live state reported through the heartbeat. Written by the event loop and read
253
+ #: by the heartbeat thread; plain dict operations are atomic enough under the GIL
254
+ #: and a lost update only costs one cycle of freshness.
255
+ _HEARTBEAT_STATE = {
256
+ "connected": False,
257
+ "connects": 0,
258
+ "last_tool": "",
259
+ "last_tool_at": 0.0,
260
+ "last_error": "",
261
+ "started_at": 0.0,
262
+ }
263
+
264
+
265
+ def runtime_facts() -> dict:
266
+ """Interpreter, dependency and location facts of this agent process."""
267
+ global _RUNTIME_FACTS
268
+ if _RUNTIME_FACTS is None:
269
+ version = getattr(websockets, "__version__", "MISSING") if websockets else "MISSING"
270
+ _RUNTIME_FACTS = {
271
+ "python": "%d.%d.%d" % sys.version_info[:3],
272
+ "interpreter": sys.executable or "",
273
+ "websockets": version,
274
+ "node_version": version_module.__version__,
275
+ }
276
+ facts = dict(_RUNTIME_FACTS)
277
+ # Read fresh: tests (and a re-configured process) may change these.
278
+ facts["gateway"] = GATEWAY_HOST
279
+ facts["user"] = USER
280
+ return facts
281
+
282
+
283
+ def log_runtime_banner() -> dict:
284
+ """Log one line naming the interpreter a launcher actually started.
285
+
286
+ The Windows incident produced no such line: the launcher ran the Microsoft
287
+ Store alias, which printed ``Python `` and exited, so nothing in the log said
288
+ which interpreter was expected.
289
+ """
290
+ facts = runtime_facts()
291
+ logger.info("Runtime: node=%s interpreter=%s python=%s websockets=%s cwd=%s pid=%s",
292
+ facts.get("node_version") or version_module.__version__,
293
+ facts["interpreter"] or "<unknown>", facts["python"],
294
+ facts["websockets"], os.getcwd(), os.getpid())
295
+ if _WEBSOCKETS_IMPORT_ERROR is not None:
296
+ logger.error("The 'websockets' package is not importable by this interpreter (%s): %s. "
297
+ "The tunnel cannot start; run: %s -m pip install websockets",
298
+ facts["interpreter"] or "<unknown>", _WEBSOCKETS_IMPORT_ERROR,
299
+ facts["interpreter"] or "python")
300
+ if "\\WindowsApps\\" in facts["interpreter"] or "/WindowsApps/" in facts["interpreter"]:
301
+ logger.error("This agent runs through the Microsoft Store 'python.exe' alias (%s). That alias "
302
+ "stops working after a Store repair or an update; the installer must pin a real "
303
+ "interpreter instead.", facts["interpreter"])
304
+ return facts
305
+
306
+
307
+ def write_heartbeat(path: Optional[str] = None, **extra) -> dict:
308
+ """Atomically refresh the heartbeat file read by the watchdog and the doctor."""
309
+ target = path or HEARTBEAT_FILE
310
+ payload = {"ts": time.time(), "pid": os.getpid()}
311
+ payload.update(runtime_facts())
312
+ payload.update(_HEARTBEAT_STATE)
313
+ payload.update(extra)
314
+ try:
315
+ directory = os.path.dirname(target)
316
+ if directory:
317
+ os.makedirs(directory, exist_ok=True)
318
+ temporary = target + ".tmp"
319
+ with open(temporary, "w", encoding="utf-8") as handle:
320
+ json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
321
+ handle.write("\n")
322
+ os.replace(temporary, target)
323
+ except Exception as exc: # diagnostics never break the tunnel
324
+ logger.debug("Could not write the heartbeat %s: %s", target, exc)
325
+ return payload
326
+
327
+
328
+ def read_heartbeat(path: Optional[str] = None) -> dict:
329
+ """The last heartbeat, or an empty dict when there is none or it is unreadable."""
330
+ target = path or HEARTBEAT_FILE
331
+ try:
332
+ with open(target, "r", encoding="utf-8") as handle:
333
+ data = json.load(handle)
334
+ except Exception:
335
+ return {}
336
+ return data if isinstance(data, dict) else {}
337
+
338
+
339
+ def heartbeat_age(path: Optional[str] = None) -> float:
340
+ """Seconds since the last heartbeat; ``inf`` when there is none."""
341
+ try:
342
+ return max(0.0, time.time() - float(read_heartbeat(path).get("ts")))
343
+ except Exception:
344
+ return float("inf")
345
+
346
+
347
+ def mark_connected(connected: bool, reason: str = "") -> None:
348
+ """Record the tunnel state for the heartbeat."""
349
+ _HEARTBEAT_STATE["connected"] = bool(connected)
350
+ if connected:
351
+ _HEARTBEAT_STATE["connects"] = int(_HEARTBEAT_STATE.get("connects", 0)) + 1
352
+ _HEARTBEAT_STATE["last_error"] = ""
353
+ elif reason:
354
+ _HEARTBEAT_STATE["last_error"] = str(reason)[:300]
355
+
356
+
357
+ def start_heartbeat(path: Optional[str] = None, interval: float = HEARTBEAT_INTERVAL):
358
+ """Start the daemon heartbeat thread (it must never keep the process alive)."""
359
+ _HEARTBEAT_STATE["started_at"] = time.time()
360
+ write_heartbeat(path)
361
+
362
+ def _beat():
363
+ while True:
364
+ time.sleep(interval)
365
+ write_heartbeat(path)
366
+
367
+ thread = threading.Thread(target=_beat, name="mesh-heartbeat", daemon=True)
368
+ thread.start()
369
+ return thread
370
+
371
+
372
+ def enable_fault_log(path: Optional[str] = None) -> Optional[str]:
373
+ """Send fatal-error tracebacks (native crash, deadlock) to their own file.
374
+
375
+ Without this the only trace of an abnormal end is a torn line in agent.log.
376
+ """
377
+ global _FAULT_HANDLE
378
+ target = path or FAULT_FILE
379
+ try:
380
+ directory = os.path.dirname(target)
381
+ if directory:
382
+ os.makedirs(directory, exist_ok=True)
383
+ _FAULT_HANDLE = open(target, "a", encoding="utf-8")
384
+ faulthandler.enable(file=_FAULT_HANDLE, all_threads=True)
385
+ except Exception as exc:
386
+ logger.debug("Could not enable the fault log %s: %s", target, exc)
387
+ return None
388
+ return target
389
+
390
+
391
+ def write_stop_marker(reason: str = "stop") -> None:
392
+ """Record a clean stop: a missing marker means the process was killed."""
393
+ try:
394
+ directory = os.path.dirname(STOP_MARKER_FILE)
395
+ if directory:
396
+ os.makedirs(directory, exist_ok=True)
397
+ with open(STOP_MARKER_FILE, "w", encoding="utf-8") as handle:
398
+ handle.write("%s pid=%s reason=%s\n"
399
+ % (time.strftime("%Y-%m-%dT%H:%M:%S"), os.getpid(), reason))
400
+ except Exception:
401
+ pass
402
+
403
+
404
+ def install_exit_markers() -> None:
405
+ """Write the clean-stop marker on a normal exit or on a received signal."""
406
+ atexit.register(write_stop_marker, "atexit")
407
+
408
+ def _on_signal(signum, _frame):
409
+ write_stop_marker("signal=%s" % signum)
410
+ raise SystemExit(0)
411
+
412
+ for name in ("SIGINT", "SIGTERM", "SIGBREAK"):
413
+ number = getattr(signal, name, None)
414
+ if number is None:
415
+ continue
416
+ try:
417
+ signal.signal(number, _on_signal)
418
+ except Exception:
419
+ continue
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Self-update
424
+ #
425
+ # The updater itself lives in core/updater.py and is deliberately unaware of this
426
+ # module: it can check, download, verify and swap a payload with no agent around
427
+ # (ops/update.ps1 and the CLI drive it that way). What only the agent can do is
428
+ # stop cleanly so the swapped code is what runs next - that is the hook below.
429
+ # ---------------------------------------------------------------------------
430
+
431
+ #: The updater's last answer, carried in every heartbeat so ops/doctor.ps1 can
432
+ #: show "a newer release exists" (or "the last check failed") without a network.
433
+ _UPDATE_STATE: dict = {}
434
+ _RESTART_EVENT = threading.Event()
435
+
436
+
437
+ def note_update_state(result: dict) -> None:
438
+ """Record one updater answer in the heartbeat."""
439
+ if not isinstance(result, dict):
440
+ return
441
+ check = result.get("check") if isinstance(result.get("check"), dict) else result
442
+ payload = {
443
+ "current": check.get("current") or version_module.__version__,
444
+ "latest": check.get("latest") or "",
445
+ "available": bool(check.get("update_available")),
446
+ "checked_at": check.get("checked_at") or "",
447
+ "reason": str(check.get("reason") or "")[:200],
448
+ }
449
+ applied = result.get("apply")
450
+ if isinstance(applied, dict) and applied.get("applied"):
451
+ payload["applied"] = applied.get("to") or ""
452
+ _UPDATE_STATE.clear()
453
+ _UPDATE_STATE.update(payload)
454
+ _HEARTBEAT_STATE["update"] = dict(_UPDATE_STATE)
455
+ write_heartbeat()
456
+
457
+
458
+ def request_restart(delay: float = 5.0) -> bool:
459
+ """Stop this agent (once) so the restart helper can start the updated copy.
460
+
461
+ The delay is what lets an in-flight tool result reach the caller: a
462
+ ``mesh_update`` call answers "applied and restarting" and only then does the
463
+ process leave, which is also what releases the tunnel and the instance lock
464
+ the new agent needs.
465
+ """
466
+ if _RESTART_EVENT.is_set():
467
+ return False
468
+ _RESTART_EVENT.set()
469
+ name = "SIGBREAK" if os.name == "nt" else "SIGTERM"
470
+ number = getattr(signal, name, None)
471
+ if number is None:
472
+ number = getattr(signal, "SIGTERM", None)
473
+
474
+ def _fire():
475
+ time.sleep(max(0.0, delay))
476
+ logger.warning("Stopping this agent so the updated version can start (signal %s).", name)
477
+ try:
478
+ signal.raise_signal(number)
479
+ except Exception:
480
+ # A platform without raise_signal still has to end up updated; the
481
+ # restart helper is already waiting for this pid either way.
482
+ os._exit(0)
483
+
484
+ threading.Thread(target=_fire, name="mesh-restart", daemon=True).start()
485
+ return True
486
+
487
+
488
+ def start_update_thread():
489
+ """Check for a newer release in the background and install it.
490
+
491
+ The first check is delayed (``MESH_UPDATE_INITIAL_DELAY``, 90 s by default)
492
+ so a node that was just started - including one that was just updated - gets
493
+ its tunnel up and serves a few calls before anything restarts.
494
+ """
495
+ config = updater.config_from_env()
496
+ if not config["enabled"]:
497
+ logger.info("Self-update is disabled (MESH_UPDATE_CHECK=0).")
498
+ return None
499
+ # Registered even when automatic updating is off: an operator (or the model
500
+ # through mesh_update) can still ask for an update, and the node then has to
501
+ # stop itself for the new version to run.
502
+ updater.set_restart_hook(request_restart)
503
+ try:
504
+ initial = float(os.environ.get("MESH_UPDATE_INITIAL_DELAY", "") or 90.0)
505
+ except ValueError:
506
+ initial = 90.0
507
+
508
+ def _loop():
509
+ time.sleep(max(0.0, initial))
510
+ while True:
511
+ try:
512
+ result = updater.background_tick(config=config)
513
+ note_update_state(result)
514
+ check = result.get("check") or {}
515
+ applied = result.get("apply") or {}
516
+ if applied.get("applied"):
517
+ logger.warning("Self-update: %s -> %s applied from %s; restarting.",
518
+ applied.get("from"), applied.get("to"), applied.get("asset"))
519
+ elif check.get("update_available"):
520
+ logger.info("Self-update: release %s is available (%s).",
521
+ check.get("latest"), check.get("reason"))
522
+ elif check.get("ok"):
523
+ logger.debug("Self-update: up to date (%s).", check.get("current"))
524
+ else:
525
+ logger.warning("Self-update check failed: %s", check.get("reason"))
526
+ except Exception as exc: # the updater must never take the node down
527
+ logger.warning("Self-update iteration failed: %s", exc)
528
+ time.sleep(max(60, int(config["interval"])))
529
+
530
+ thread = threading.Thread(target=_loop, name="mesh-updater", daemon=True)
531
+ thread.start()
532
+ return thread
533
+
534
+
535
+ # ---------------------------------------------------------------------------
536
+ # Single instance per node name
537
+ # ---------------------------------------------------------------------------
538
+
539
+ class InstanceLock:
540
+ """Non-blocking, OS-released file lock (fcntl on POSIX, msvcrt on Windows).
541
+
542
+ The OS drops the lock when the process dies, so a crash never leaves a stale
543
+ lock behind.
544
+ """
545
+
546
+ def __init__(self, path: str):
547
+ self.path = path
548
+ self._fh = None
549
+
550
+ def try_acquire(self) -> bool:
551
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
552
+ fh = open(self.path, "a+")
553
+ try:
554
+ if os.name == "nt":
555
+ import msvcrt
556
+ fh.seek(0)
557
+ msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
558
+ else:
559
+ import fcntl
560
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
561
+ except OSError:
562
+ fh.close()
563
+ return False
564
+ fh.seek(0)
565
+ fh.truncate()
566
+ fh.write(str(os.getpid()))
567
+ fh.flush()
568
+ self._fh = fh
569
+ return True
570
+
571
+
572
+ def wait_for_instance_lock(user: str) -> InstanceLock:
573
+ """Block until this process is the only agent for ``user`` on the machine."""
574
+ safe = "".join(c for c in user if c.isalnum() or c in "-_") or "node"
575
+ lock = InstanceLock(os.path.join(CONFIG_DIR, "agent-%s.lock" % safe))
576
+ warned = False
577
+ while not lock.try_acquire():
578
+ if not warned:
579
+ logger.warning("Another agent for node '%s' is already running on this machine; "
580
+ "waiting for it to exit instead of fighting over the tunnel.", user)
581
+ warned = True
582
+ time.sleep(10)
583
+ return lock
584
+
585
+
586
+ # ---------------------------------------------------------------------------
587
+ # Tunnel
588
+ # ---------------------------------------------------------------------------
589
+
56
590
  async def handle_tool_call(name: str, args: dict) -> dict:
57
591
  """Forward one tool call to the shared implementation in a worker thread."""
58
592
  logger.info("Executing %s", name)
59
- return await asyncio.to_thread(mcp_tools.call_tool, name, args or {})
593
+ _HEARTBEAT_STATE["last_tool"] = name or ""
594
+ _HEARTBEAT_STATE["last_tool_at"] = time.time()
595
+ try:
596
+ return await asyncio.to_thread(mcp_tools.call_tool, name, args or {})
597
+ finally:
598
+ # A heartbeat right after every call makes "the agent is alive and served
599
+ # this tool" visible to the watchdog without reading the tunnel state.
600
+ write_heartbeat()
601
+
602
+
603
+ async def _serve_call(ws, req_id, params: dict) -> None:
604
+ tool_name = params.get("name")
605
+ tool_args = params.get("arguments", {})
606
+ try:
607
+ res = await handle_tool_call(tool_name, tool_args)
608
+ except Exception as tool_exc:
609
+ # A tool must never take the tunnel down with it.
610
+ logger.warning(f"Tool {tool_name} failed: {tool_exc}")
611
+ _HEARTBEAT_STATE["last_error"] = "%s: %s" % (type(tool_exc).__name__, tool_exc)
612
+ res = {"error": f"{type(tool_exc).__name__}: {tool_exc}"}
613
+ try:
614
+ await ws.send(json.dumps({"id": req_id, "result": res}, ensure_ascii=False))
615
+ except Exception as send_exc:
616
+ # The link dropped while the tool ran; the gateway already failed the
617
+ # call, and the reconnect loop restores the tunnel.
618
+ logger.warning(f"Could not deliver result of {tool_name}: {send_exc}")
619
+ _HEARTBEAT_STATE["last_error"] = "send failed: %s" % send_exc
620
+ write_heartbeat()
621
+
622
+
623
+ def _is_unauthorized(exc: BaseException) -> bool:
624
+ """True when the gateway rejected the node's credentials.
625
+
626
+ The gateway closes with 4001 before accepting the websocket; Starlette turns
627
+ that into an HTTP 403 handshake response, so depending on timing the client
628
+ sees either the close code or the HTTP status. Handles websockets' legacy
629
+ and new exception shapes.
630
+ """
631
+ for attr in ("rcvd", "received"):
632
+ frame = getattr(exc, attr, None)
633
+ if frame is not None and getattr(frame, "code", None) == 4001:
634
+ return True
635
+ if getattr(exc, "code", None) == 4001:
636
+ return True
637
+ status = getattr(exc, "status_code", None) # legacy InvalidStatusCode
638
+ response = getattr(exc, "response", None) # websockets >= 14 InvalidStatus
639
+ if status is None and response is not None:
640
+ status = getattr(response, "status_code", None)
641
+ return status in (401, 403)
642
+
643
+
644
+ def _looks_like_dns_failure(exc: BaseException) -> bool:
645
+ """True when the connection failed because the gateway name did not resolve.
646
+
647
+ A wrong or dead gateway domain produced an endless stream of generic
648
+ "Connection lost" lines, which reads like a network outage. Naming the cause
649
+ once is the difference between a five-minute fix and a long hunt.
650
+ """
651
+ if isinstance(exc, socket.gaierror):
652
+ return True
653
+ text = str(exc).lower()
654
+ return any(marker in text for marker in (
655
+ "name or service not known", # Linux getaddrinfo
656
+ "nodename nor servname", # BSD / macOS
657
+ "getaddrinfo failed", # Windows
658
+ "temporary failure in name resolution",
659
+ "no address associated with hostname",
660
+ ))
661
+
662
+
663
+ def tunnel_uri() -> str:
664
+ """Canonical tunnel URL: one shared domain, node selected by ?user=."""
665
+ base = GATEWAY_HOST.rstrip("/")
666
+ if not base.startswith(("ws://", "wss://")):
667
+ base = "wss://" + base
668
+ return f"{base}/ws/tunnel?user={USER}&token={TOKEN}"
60
669
 
61
670
 
62
671
  async def run_agent():
@@ -67,40 +676,92 @@ async def run_agent():
67
676
  hammering loop when the network is genuinely down. The backoff resets after a
68
677
  healthy connection, so a long-lived session is not penalised.
69
678
  """
70
- uri = f"wss://{GATEWAY_HOST}/ws/tunnel?user={USER}&token={TOKEN}"
71
- logger.info(f"Connecting to Gateway {uri}...")
679
+ if not TOKEN:
680
+ logger.error("MESH_TOKEN is empty (env and %s): the gateway will reject this node. "
681
+ "Re-run the installer.", os.environ.get("MESH_CONFIG_FILE") or DEFAULT_CONFIG_FILE)
682
+ uri = tunnel_uri()
683
+ logger.info(f"Connecting to Gateway {uri.split('&token=')[0]} ...")
72
684
  backoff = 1.0
685
+ in_flight = set()
73
686
  while True:
74
687
  try:
75
688
  async with websockets.connect(uri, ping_interval=10, ping_timeout=10,
76
- close_timeout=5) as ws:
689
+ close_timeout=5, open_timeout=15,
690
+ max_size=None) as ws:
77
691
  logger.info(f"Connected to Mesh Gateway as '{USER}'!")
78
692
  backoff = 1.0 # healthy again
693
+ mark_connected(True)
694
+ write_heartbeat()
79
695
  async for raw_msg in ws:
80
696
  try:
81
697
  data = json.loads(raw_msg)
82
698
  except Exception:
83
699
  continue
84
- req_id = data.get("id")
85
- method = data.get("method")
86
- params = data.get("params", {})
87
- if method == "tools/call":
88
- tool_name = params.get("name")
89
- tool_args = params.get("arguments", {})
90
- try:
91
- res = await handle_tool_call(tool_name, tool_args)
92
- except Exception as tool_exc:
93
- # A tool must never take the tunnel down with it.
94
- logger.warning(f"Tool {tool_name} failed: {tool_exc}")
95
- res = {"error": f"{type(tool_exc).__name__}: {tool_exc}"}
96
- resp = {"id": req_id, "result": res}
97
- await ws.send(json.dumps(resp, ensure_ascii=False))
700
+ if data.get("method") == "tools/call":
701
+ # Each call runs on its own: Gemini issues parallel calls,
702
+ # and serialising them pushed later ones past the
703
+ # gateway's 28 s limit.
704
+ task = asyncio.create_task(_serve_call(ws, data.get("id"), data.get("params", {})))
705
+ in_flight.add(task)
706
+ task.add_done_callback(in_flight.discard)
707
+ # A clean close (e.g. gateway restart) still needs a reconnect.
708
+ raise ConnectionError("tunnel closed by gateway")
709
+ except asyncio.CancelledError:
710
+ raise
98
711
  except Exception as e:
99
- delay = min(backoff, 15.0) + random.uniform(0, 0.5)
100
- logger.warning(f"Connection lost: {e}. Reconnecting in {delay:.1f}s...")
712
+ # The heartbeat is how a watchdog - and an operator reading the files
713
+ # later - can tell "running but disconnected" from "not running".
714
+ mark_connected(False, "%s: %s" % (type(e).__name__, e))
715
+ write_heartbeat()
716
+ if _is_unauthorized(e):
717
+ delay = UNAUTHORIZED_BACKOFF
718
+ logger.error("Gateway rejected node '%s' (4001 Unauthorized): the token is wrong or the "
719
+ "node was removed. Re-run the installer. Retrying in %.0fs.", USER, delay)
720
+ elif _looks_like_dns_failure(e):
721
+ delay = min(backoff, MAX_BACKOFF) + random.uniform(0, 0.5)
722
+ logger.error("Gateway '%s' does not resolve (%s). Check the gateway name (MESH_GATEWAY) "
723
+ "and this machine's DNS. Retrying in %.0fs.", GATEWAY_HOST, e, delay)
724
+ backoff = min(backoff * 2, MAX_BACKOFF)
725
+ else:
726
+ delay = min(backoff, MAX_BACKOFF) + random.uniform(0, 0.5)
727
+ logger.warning(f"Connection lost: {e}. Reconnecting in {delay:.1f}s...")
728
+ backoff = min(backoff * 2, MAX_BACKOFF)
101
729
  await asyncio.sleep(delay)
102
- backoff = min(backoff * 2, 15.0)
730
+
731
+
732
+ def main() -> None:
733
+ # Fail fast on a configuration that can never work, instead of retrying the
734
+ # gateway every 60 seconds forever from a Windows Startup launcher.
735
+ problem = configuration_error()
736
+ if problem:
737
+ logger.error("%s Refusing to start.", problem)
738
+ sys.exit(2)
739
+ install_exit_markers()
740
+ enable_fault_log()
741
+ log_runtime_banner()
742
+ # An update that was interrupted mid-swap (a kill, a power cut) is settled
743
+ # here, before the tunnel starts: either finished or rolled back.
744
+ recovery = updater.recover_pending()
745
+ if recovery:
746
+ logger.warning("Self-update recovery: %s", recovery)
747
+ start_heartbeat()
748
+ if _WEBSOCKETS_IMPORT_ERROR is not None:
749
+ # The banner above already named the interpreter and the missing module.
750
+ logger.error("Refusing to start: this interpreter cannot import 'websockets'.")
751
+ sys.exit(3)
752
+ _lock = wait_for_instance_lock(USER) # held for the life of the process
753
+ write_heartbeat()
754
+ logger.info("Instance lock acquired: this is the only agent for node '%s'.", USER)
755
+ # Only the agent that owns the node updates it: a second instance waiting for
756
+ # the lock must not swap files under the process that is actually running.
757
+ start_update_thread()
758
+ try:
759
+ asyncio.run(run_agent())
760
+ except KeyboardInterrupt:
761
+ sys.exit(0)
762
+ finally:
763
+ write_heartbeat(stopping=True)
103
764
 
104
765
 
105
766
  if __name__ == "__main__":
106
- asyncio.run(run_agent())
767
+ main()