gemini-computer-use 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1086
- package/cli.js +56 -0
- package/package.json +14 -24
- package/LICENSE +0 -21
- package/bin/cli.js +0 -122
- package/core/__init__.py +0 -0
- package/core/agent.py +0 -767
- package/core/agent_harness.py +0 -67
- package/core/code_cache.py +0 -244
- package/core/device.py +0 -2400
- package/core/domain.py +0 -303
- package/core/hooks.py +0 -61
- package/core/mcp_tools.py +0 -3497
- package/core/policies.py +0 -132
- package/core/schemas.py +0 -49
- package/core/server.py +0 -972
- package/core/subagents.py +0 -55
- package/core/termux.py +0 -916
- package/core/triggers.py +0 -70
- package/core/updater.py +0 -1713
- package/core/version.py +0 -31
- package/core/vitals.py +0 -181
- package/core/web_share.py +0 -814
- package/gateway.py +0 -2862
- package/install-gui.cmd +0 -40
- package/install-gui.ps1 +0 -1882
- package/install-gui.strings.json +0 -211
- package/install.ps1 +0 -1287
- package/install.sh +0 -2013
- package/ops/doctor.ps1 +0 -735
- package/ops/mesh-domain.sh +0 -86
- package/ops/nginx/README.md +0 -77
- package/ops/nginx/antigravity-mesh-mcp.conf +0 -172
- package/ops/nginx/mesh-domain.vhost.template +0 -58
- package/ops/nginx/render-domain.sh +0 -123
- package/ops/update.ps1 +0 -373
- package/ops/windows/README.md +0 -298
- package/ops/windows/agent-watchdog.ps1 +0 -575
- package/ops/windows/run-hidden.vbs +0 -64
- package/skills/orchestrator.md +0 -26
package/core/agent.py
DELETED
|
@@ -1,767 +0,0 @@
|
|
|
1
|
-
"""core/agent.py - Antigravity Mesh tunnel node (thin transport).
|
|
2
|
-
|
|
3
|
-
All tool logic lives in :mod:`core.mcp_tools`; this module only maintains the
|
|
4
|
-
websocket tunnel to the gateway and forwards ``tools/call`` requests. The
|
|
5
|
-
response shape is unchanged (``stdout``/``stderr``/``exit_code`` and friends),
|
|
6
|
-
so an unmodified gateway keeps working.
|
|
7
|
-
|
|
8
|
-
Configuration (read once at import/startup)::
|
|
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)
|
|
20
|
-
MESH_WORKSPACE base directory for relative paths (default: cwd)
|
|
21
|
-
MESH_READ_ONLY "1"/"true" -> mutating tools refuse to run
|
|
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.
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
import asyncio
|
|
40
|
-
import atexit
|
|
41
|
-
import faulthandler
|
|
42
|
-
import json
|
|
43
|
-
import logging
|
|
44
|
-
import os
|
|
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
|
|
53
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
from core import domain, mcp_tools, updater
|
|
63
|
-
from core import version as version_module
|
|
64
|
-
|
|
65
|
-
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
66
|
-
logger = logging.getLogger("agy-agent")
|
|
67
|
-
|
|
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"]
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def _env_flag(name: str) -> bool:
|
|
197
|
-
return str(os.environ.get(name, "")).strip().lower() in ("1", "true", "yes", "on")
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def configure_from_env() -> None:
|
|
201
|
-
"""Apply MESH_* environment configuration to the shared tool module."""
|
|
202
|
-
write_roots = os.environ.get("MESH_WRITE_ROOTS") or None
|
|
203
|
-
jobs_dir = os.environ.get("MESH_JOBS_DIR") or None
|
|
204
|
-
max_chars = os.environ.get("MESH_MAX_OUTPUT_CHARS")
|
|
205
|
-
mcp_tools.configure(
|
|
206
|
-
workspace=os.environ.get("MESH_WORKSPACE") or os.getcwd(),
|
|
207
|
-
read_only=_env_flag("MESH_READ_ONLY"),
|
|
208
|
-
write_roots=write_roots,
|
|
209
|
-
jobs_dir=jobs_dir,
|
|
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"),
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
# Configure as soon as the node module is imported so external callers (tests,
|
|
235
|
-
# harnesses) observe the environment-driven settings.
|
|
236
|
-
configure_from_env()
|
|
237
|
-
|
|
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
|
-
|
|
590
|
-
async def handle_tool_call(name: str, args: dict) -> dict:
|
|
591
|
-
"""Forward one tool call to the shared implementation in a worker thread."""
|
|
592
|
-
logger.info("Executing %s", name)
|
|
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}"
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
async def run_agent():
|
|
672
|
-
"""Stay connected to the gateway, healing every kind of link failure.
|
|
673
|
-
|
|
674
|
-
A dropped websocket is retried with exponential backoff plus jitter: a short
|
|
675
|
-
first retry restores a flapping link quickly, while the growing cap stops a
|
|
676
|
-
hammering loop when the network is genuinely down. The backoff resets after a
|
|
677
|
-
healthy connection, so a long-lived session is not penalised.
|
|
678
|
-
"""
|
|
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]} ...")
|
|
684
|
-
backoff = 1.0
|
|
685
|
-
in_flight = set()
|
|
686
|
-
while True:
|
|
687
|
-
try:
|
|
688
|
-
async with websockets.connect(uri, ping_interval=10, ping_timeout=10,
|
|
689
|
-
close_timeout=5, open_timeout=15,
|
|
690
|
-
max_size=None) as ws:
|
|
691
|
-
logger.info(f"Connected to Mesh Gateway as '{USER}'!")
|
|
692
|
-
backoff = 1.0 # healthy again
|
|
693
|
-
mark_connected(True)
|
|
694
|
-
write_heartbeat()
|
|
695
|
-
async for raw_msg in ws:
|
|
696
|
-
try:
|
|
697
|
-
data = json.loads(raw_msg)
|
|
698
|
-
except Exception:
|
|
699
|
-
continue
|
|
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
|
|
711
|
-
except Exception as e:
|
|
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)
|
|
729
|
-
await asyncio.sleep(delay)
|
|
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)
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
if __name__ == "__main__":
|
|
767
|
-
main()
|