gemini-computer-use 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/updater.py DELETED
@@ -1,1713 +0,0 @@
1
- """core/updater.py - self-update from the project's GitHub releases.
2
-
3
- The node installs itself from a release and then keeps itself current the same
4
- way: it asks the GitHub Releases API what the newest published version is,
5
- compares it with :mod:`core.version`, downloads the matching release asset,
6
- verifies its SHA-256, swaps ``core/``/``skills/``/``ops/`` in place with a
7
- backup it can roll back to, and restarts the agent through whatever supervises
8
- it (a Windows Scheduled Task, a systemd user unit, a launchd agent, or a plain
9
- detached ``python -m core.agent``).
10
-
11
- Design contract
12
- ---------------
13
-
14
- * **Standard library only.** The node has exactly one third-party dependency
15
- (``websockets``) and this module must not add a second one: it runs on a
16
- Windows box with nothing but python.org's Python, on a VPS, and on macOS.
17
- * **Fail closed.** An update is applied only when the payload could be
18
- downloaded, its structure verified, and (unless the operator deliberately
19
- opted out) its SHA-256 matched a published checksum. A half-verified payload
20
- never touches an installed node.
21
- * **Never leave a broken node.** Every swap is preceded by a backup and a
22
- journal entry. A process killed mid-swap rolls back on the next start
23
- (:func:`recover_pending`), so the worst case is "still on the old version".
24
- * **One code path for check and apply.** The background thread in
25
- :mod:`core.agent`, the MCP tool ``mesh_update``, ``ops/update.ps1`` and
26
- ``bin/cli.js update`` all call the functions here, so they cannot disagree
27
- about what "up to date" means.
28
-
29
- Configuration (``MESH_*`` keys are read from the environment, and therefore also
30
- from ``agent.env``, which :mod:`core.agent` exports at startup)::
31
-
32
- MESH_UPDATE_CHECK 1/0, default 1: check at all
33
- MESH_UPDATE_AUTO 1/0, default 1: apply a found update by itself
34
- MESH_UPDATE_CHECK_INTERVAL seconds between checks, default 21600 (6 h), min 300
35
- MESH_UPDATE_CHANNEL "stable" (default) or "prerelease"
36
- MESH_UPDATE_TOKEN GitHub token, for the 60 -> 5000 requests/hour limit
37
- (GITHUB_TOKEN / GH_TOKEN are honoured too)
38
- MESH_UPDATE_REPO owner/name override, default core.version.REPO_SLUG
39
- MESH_UPDATE_DIR install directory override (default: the directory
40
- holding this package)
41
- MESH_UPDATE_RESTART 1/0, default 1: restart the agent after applying
42
- MESH_UPDATE_ALLOW_UNVERIFIED
43
- 1/0, default 0: accept a payload with no published
44
- checksum (source tarball fallback)
45
- MESH_UPDATE_KEEP_BACKUPS how many old versions to keep, default 3
46
- MESH_UPDATE_STATE path of the state file
47
- MESH_UPDATE_STATE_FILE alias of MESH_UPDATE_STATE
48
- MESH_UPDATE_DRY_RUN 1/0, default 0: stage and verify, change nothing
49
-
50
- Command line (used by ``ops/update.ps1`` and the CLI)::
51
-
52
- python -m core.updater --status [--json] report local state, no network
53
- python -m core.updater --check [--json] ask GitHub; exit 2 when an update exists
54
- python -m core.updater --apply [--json] check, download, verify, swap, restart
55
- python -m core.updater --restart-helper ... internal: wait for a pid, then restart
56
-
57
- Exit codes::
58
-
59
- 0 up to date, or the requested action completed
60
- 2 an update is available and was not applied (--check)
61
- 3 the action failed (network, checksum mismatch, unusable payload)
62
- 4 the update was applied and the node is restarting (nothing left to do)
63
- """
64
-
65
- from __future__ import annotations
66
-
67
- import argparse
68
- import hashlib
69
- import json
70
- import logging
71
- import os
72
- import re
73
- import shutil
74
- import signal
75
- import ssl
76
- import subprocess
77
- import sys
78
- import tarfile
79
- import tempfile
80
- import time
81
- import urllib.error
82
- import urllib.parse
83
- import urllib.request
84
- import zipfile
85
- from typing import Any, Dict, List, Optional, Sequence, Tuple
86
-
87
- from core import version as version_module
88
-
89
- logger = logging.getLogger("agy-updater")
90
-
91
- # ---------------------------------------------------------------------------
92
- # Identity
93
- # ---------------------------------------------------------------------------
94
-
95
- #: Repository that publishes the releases this node updates itself from.
96
- REPO = version_module.REPO_SLUG
97
-
98
- API_ROOT = "https://api.github.com"
99
- USER_AGENT = "AntigravityMesh-Updater/%s (+https://github.com/%s)" % (
100
- version_module.__version__, REPO)
101
-
102
- #: What an update replaces inside the install directory. Everything else in that
103
- #: directory (install-gui.strings.json, a checkout's docs/, templates/) is left
104
- #: exactly as it is: the updater owns the node payload, not the whole working tree.
105
- PAYLOAD_DIRS: Tuple[str, ...] = ("core", "skills", "ops")
106
- PAYLOAD_FILES: Tuple[str, ...] = ("package.json",)
107
-
108
- #: A payload without these is not a node, whatever its zip says.
109
- REQUIRED_PATHS: Tuple[str, ...] = (
110
- "core/agent.py",
111
- "core/mcp_tools.py",
112
- "core/updater.py",
113
- "core/version.py",
114
- "core/__init__.py",
115
- )
116
-
117
- #: Names the release is expected to carry, newest format first.
118
- PAYLOAD_ASSET = "mesh-payload-%s.zip"
119
- SETUP_ASSET = "AntigravityMesh-Setup-%s.exe"
120
-
121
- HTTP_TIMEOUT = 20.0
122
- DOWNLOAD_TIMEOUT = 120.0
123
- #: Sanity ceiling for a payload download (a release asset is a few hundred KB).
124
- MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024
125
-
126
- DEFAULT_CHECK_INTERVAL = 6 * 60 * 60
127
- MIN_CHECK_INTERVAL = 300
128
- #: After a failed apply, do not hammer the same release again immediately.
129
- APPLY_RETRY_BACKOFF = 60 * 60
130
-
131
- CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "antigravity-mesh")
132
- STATE_FILE = os.path.join(CONFIG_DIR, "update.state.json")
133
- JOURNAL_FILE = os.path.join(CONFIG_DIR, "update.journal.json")
134
- LOG_FILE = os.path.join(CONFIG_DIR, "update.log")
135
- BACKUP_ROOT = os.path.join(CONFIG_DIR, "backups")
136
- HEARTBEAT_FILE = os.path.join(CONFIG_DIR, "agent.heartbeat")
137
- WATCHDOG = os.path.join("ops", "windows", "agent-watchdog.ps1")
138
- SYSTEMD_UNIT = "agy-agent.service"
139
- LAUNCHD_LABEL = "com.antigravity.mesh.agent"
140
-
141
- _VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+]([0-9A-Za-z.\-]+))?$")
142
- _VERSION_LITERAL_RE = re.compile(r"^__version__\s*=\s*[\"']([^\"']+)[\"']", re.M)
143
- _SHA256_RE = re.compile(r"\b([0-9a-fA-F]{64})\b")
144
-
145
-
146
- class UpdateError(RuntimeError):
147
- """A failed check, download, verification or swap, with a human reason."""
148
-
149
-
150
- class RateLimited(UpdateError):
151
- """GitHub refused the request because the rate limit was reached."""
152
-
153
- def __init__(self, message: str, retry_after: float = 0.0, reset_at: float = 0.0):
154
- super().__init__(message)
155
- self.retry_after = max(0.0, float(retry_after or 0.0))
156
- self.reset_at = max(0.0, float(reset_at or 0.0))
157
-
158
-
159
- # ---------------------------------------------------------------------------
160
- # Small utilities
161
- # ---------------------------------------------------------------------------
162
-
163
- def now_iso() -> str:
164
- return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
165
-
166
-
167
- def _env_flag(name: str, default: bool = False, env: Optional[Dict[str, str]] = None) -> bool:
168
- source = os.environ if env is None else env
169
- raw = source.get(name)
170
- if raw is None or str(raw).strip() == "":
171
- return default
172
- return str(raw).strip().lower() in ("1", "true", "yes", "on", "y")
173
-
174
-
175
- def _env_int(name: str, default: int, env: Optional[Dict[str, str]] = None) -> int:
176
- source = os.environ if env is None else env
177
- try:
178
- return int(str(source.get(name, "")).strip())
179
- except Exception:
180
- return default
181
-
182
-
183
- def _env_str(name: str, default: str = "", env: Optional[Dict[str, str]] = None) -> str:
184
- source = os.environ if env is None else env
185
- value = source.get(name)
186
- if value is None:
187
- return default
188
- value = str(value).strip()
189
- return value or default
190
-
191
-
192
- def _read_json(path: str) -> Optional[Dict[str, Any]]:
193
- try:
194
- with open(path, "r", encoding="utf-8") as handle:
195
- data = json.load(handle)
196
- except Exception:
197
- return None
198
- return data if isinstance(data, dict) else None
199
-
200
-
201
- def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
202
- directory = os.path.dirname(path)
203
- if directory:
204
- os.makedirs(directory, exist_ok=True)
205
- temporary = path + ".tmp"
206
- with open(temporary, "w", encoding="utf-8") as handle:
207
- json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
208
- handle.write("\n")
209
- os.replace(temporary, path)
210
-
211
-
212
- def file_log(message: str, path: Optional[str] = None) -> None:
213
- """Append one line to the updater journal file (never raises)."""
214
- target = path or LOG_FILE
215
- try:
216
- directory = os.path.dirname(target)
217
- if directory:
218
- os.makedirs(directory, exist_ok=True)
219
- stamp = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
220
- with open(target, "a", encoding="utf-8") as handle:
221
- handle.write("[%s] %s\n" % (stamp, message.replace("\n", " ")))
222
- except Exception:
223
- pass
224
-
225
-
226
- def sha256_file(path: str, chunk: int = 1024 * 256) -> str:
227
- digest = hashlib.sha256()
228
- with open(path, "rb") as handle:
229
- while True:
230
- block = handle.read(chunk)
231
- if not block:
232
- break
233
- digest.update(block)
234
- return digest.hexdigest()
235
-
236
-
237
- # ---------------------------------------------------------------------------
238
- # Versions
239
- # ---------------------------------------------------------------------------
240
-
241
- def parse_version(text: Any) -> Optional[Tuple[int, int, int, int, Tuple]]:
242
- """Parse ``MAJOR.MINOR.PATCH[-pre]`` (a leading ``v`` is accepted).
243
-
244
- Returns a tuple that sorts the way semver requires: the release of a version
245
- is newer than its pre-releases (``0.3.0 > 0.3.0-rc.1``), and pre-release
246
- identifiers compare segment by segment, numbers before words.
247
- """
248
- if text is None:
249
- return None
250
- match = _VERSION_RE.match(str(text).strip())
251
- if not match:
252
- return None
253
- major, minor, patch = (int(match.group(index)) for index in (1, 2, 3))
254
- pre = match.group(4) or ""
255
- if not pre:
256
- return (major, minor, patch, 1, ())
257
- segments: List[Tuple[int, int, str]] = []
258
- for chunk in re.split(r"[.\-]", pre):
259
- if chunk.isdigit():
260
- segments.append((0, int(chunk), ""))
261
- else:
262
- segments.append((1, 0, chunk))
263
- return (major, minor, patch, 0, tuple(segments))
264
-
265
-
266
- def is_newer(candidate: Any, current: Any) -> bool:
267
- """True when ``candidate`` is a strictly newer, parseable version.
268
-
269
- An unparseable candidate is never newer: installing something the node
270
- cannot compare would risk a restart loop on a malformed tag.
271
- """
272
- left = parse_version(candidate)
273
- right = parse_version(current)
274
- if left is None:
275
- return False
276
- if right is None:
277
- return False
278
- return left > right
279
-
280
-
281
- def local_version(install_dir: Optional[str] = None) -> str:
282
- """The version installed in ``install_dir`` (the running one by default).
283
-
284
- Read from ``core/version.py`` on disk rather than from the imported module,
285
- so a CLI or a watchdog that did not import the node still reports what is
286
- actually installed.
287
- """
288
- override = os.environ.get("MESH_UPDATE_LOCAL_VERSION")
289
- if override:
290
- return override.strip()
291
- root = install_dir or default_install_dir()
292
- path = os.path.join(root, "core", "version.py")
293
- try:
294
- with open(path, "r", encoding="utf-8", errors="replace") as handle:
295
- match = _VERSION_LITERAL_RE.search(handle.read())
296
- if match:
297
- return match.group(1).strip()
298
- except OSError:
299
- pass
300
- marker = os.path.join(root, "VERSION")
301
- try:
302
- with open(marker, "r", encoding="utf-8", errors="replace") as handle:
303
- text = handle.read().strip()
304
- if text:
305
- return text.split()[0]
306
- except OSError:
307
- pass
308
- return version_module.__version__
309
-
310
-
311
- # ---------------------------------------------------------------------------
312
- # Configuration
313
- # ---------------------------------------------------------------------------
314
-
315
- def default_install_dir() -> str:
316
- """The directory holding the ``core`` package this module was loaded from."""
317
- return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
318
-
319
-
320
- def config_from_env(env: Optional[Dict[str, str]] = None,
321
- install_dir: Optional[str] = None) -> Dict[str, Any]:
322
- """Every updater knob in one dict, from the environment (and thus agent.env)."""
323
- source = os.environ if env is None else env
324
- channel = _env_str("MESH_UPDATE_CHANNEL", "stable", source).lower()
325
- prerelease = channel in ("prerelease", "pre", "beta", "nightly", "insiders")
326
- interval = _env_int("MESH_UPDATE_CHECK_INTERVAL", DEFAULT_CHECK_INTERVAL, source)
327
- if interval < MIN_CHECK_INTERVAL:
328
- interval = MIN_CHECK_INTERVAL
329
- token = (_env_str("MESH_UPDATE_TOKEN", "", source)
330
- or _env_str("GITHUB_TOKEN", "", source)
331
- or _env_str("GH_TOKEN", "", source))
332
- repo = _env_str("MESH_UPDATE_REPO", REPO, source)
333
- state = (_env_str("MESH_UPDATE_STATE", "", source)
334
- or _env_str("MESH_UPDATE_STATE_FILE", "", source)
335
- or STATE_FILE)
336
- root = (install_dir
337
- or _env_str("MESH_UPDATE_DIR", "", source)
338
- or _env_str("MESH_INSTALL_DIR", "", source)
339
- or default_install_dir())
340
- return {
341
- "enabled": _env_flag("MESH_UPDATE_CHECK", True, source),
342
- "auto": _env_flag("MESH_UPDATE_AUTO", True, source),
343
- "restart": _env_flag("MESH_UPDATE_RESTART", True, source),
344
- "interval": interval,
345
- "prerelease": prerelease,
346
- "channel": "prerelease" if prerelease else "stable",
347
- "token": token,
348
- "repo": repo,
349
- "install_dir": os.path.abspath(root),
350
- "allow_unverified": _env_flag("MESH_UPDATE_ALLOW_UNVERIFIED", False, source),
351
- "keep_backups": max(0, _env_int("MESH_UPDATE_KEEP_BACKUPS", 3, source)),
352
- "dry_run": _env_flag("MESH_UPDATE_DRY_RUN", False, source),
353
- "state_file": state,
354
- "journal_file": JOURNAL_FILE,
355
- "log_file": LOG_FILE,
356
- "backup_root": BACKUP_ROOT,
357
- "heartbeat_file": HEARTBEAT_FILE,
358
- }
359
-
360
-
361
- # ---------------------------------------------------------------------------
362
- # HTTP (injectable, so the whole updater is testable without a network)
363
- # ---------------------------------------------------------------------------
364
-
365
- def _ssl_context() -> ssl.SSLContext:
366
- return ssl.create_default_context()
367
-
368
-
369
- def http_get(url: str, *, headers: Optional[Dict[str, str]] = None,
370
- timeout: float = HTTP_TIMEOUT,
371
- max_bytes: Optional[int] = None) -> Tuple[int, Dict[str, str], bytes]:
372
- """One GET with a real User-Agent; returns ``(status, headers, body)``.
373
-
374
- HTTP error statuses come back as data (a 404 from the releases API is an
375
- answer, not an exception); transport failures raise :class:`UpdateError`.
376
- """
377
- request = urllib.request.Request(url, headers=headers or {}, method="GET")
378
- if not request.get_header("User-agent"):
379
- request.add_header("User-Agent", USER_AGENT)
380
- try:
381
- with urllib.request.urlopen(request, timeout=timeout, context=_ssl_context()) as response:
382
- body = response.read() if max_bytes is None else response.read(max_bytes)
383
- return (int(getattr(response, "status", 200) or 200),
384
- {str(k).lower(): str(v) for k, v in response.headers.items()},
385
- body)
386
- except urllib.error.HTTPError as exc:
387
- raw = b""
388
- try:
389
- raw = exc.read() or b""
390
- except Exception:
391
- raw = b""
392
- return (int(exc.code),
393
- {str(k).lower(): str(v) for k, v in (exc.headers or {}).items()},
394
- raw)
395
- except urllib.error.URLError as exc:
396
- raise UpdateError("network error reaching %s: %s" % (_short(url), exc.reason))
397
- except Exception as exc: # socket.timeout, ssl.SSLError, ...
398
- raise UpdateError("network error reaching %s: %s" % (_short(url), exc))
399
-
400
-
401
- def _short(url: str) -> str:
402
- parsed = urllib.parse.urlsplit(url)
403
- return parsed.netloc or url
404
-
405
-
406
- def _api_headers(token: str = "") -> Dict[str, str]:
407
- headers = {
408
- "User-Agent": USER_AGENT,
409
- "Accept": "application/vnd.github+json",
410
- "X-GitHub-Api-Version": "2022-11-28",
411
- }
412
- if token:
413
- headers["Authorization"] = "Bearer %s" % token
414
- return headers
415
-
416
-
417
- def _raise_rate_limited(status: int, headers: Dict[str, str], body: bytes) -> None:
418
- retry_after = 0.0
419
- try:
420
- retry_after = float(headers.get("retry-after", "") or 0.0)
421
- except Exception:
422
- retry_after = 0.0
423
- reset_at = 0.0
424
- try:
425
- reset_at = float(headers.get("x-ratelimit-reset", "") or 0.0)
426
- except Exception:
427
- reset_at = 0.0
428
- text = body.decode("utf-8", "replace")[:200] if body else ""
429
- raise RateLimited(
430
- "GitHub rate limit reached (HTTP %d%s). Set MESH_UPDATE_TOKEN to raise the "
431
- "limit from 60 to 5000 requests per hour." % (status, (": " + text) if text else ""),
432
- retry_after=retry_after, reset_at=reset_at)
433
-
434
-
435
- # ---------------------------------------------------------------------------
436
- # GitHub releases
437
- # ---------------------------------------------------------------------------
438
-
439
- def version_from_tag(tag: Any) -> Optional[str]:
440
- """``v0.3.0-rc.1`` -> ``0.3.0-rc.1``; None when the tag is not a version."""
441
- match = _VERSION_RE.match(str(tag or "").strip())
442
- if not match:
443
- return None
444
- core = "%d.%d.%d" % (int(match.group(1)), int(match.group(2)), int(match.group(3)))
445
- return core + (("-" + match.group(4)) if match.group(4) else "")
446
-
447
-
448
- def _release_from_payload(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
449
- tag = str(payload.get("tag_name") or "").strip()
450
- canonical = version_from_tag(tag)
451
- if canonical is None:
452
- # A tag that is not a version (a date, a codename) cannot be compared, and
453
- # guessing is worse than skipping: another release in the list may parse.
454
- return None
455
- if payload.get("draft"):
456
- return None
457
- assets = []
458
- for asset in payload.get("assets") or []:
459
- if not isinstance(asset, dict) or not asset.get("name"):
460
- continue
461
- assets.append({
462
- "name": str(asset.get("name")),
463
- "url": str(asset.get("browser_download_url") or ""),
464
- "size": int(asset.get("size") or 0),
465
- "digest": str(asset.get("digest") or ""),
466
- "content_type": str(asset.get("content_type") or ""),
467
- })
468
- return {
469
- "tag": tag,
470
- "version": canonical,
471
- "parsed": parse_version(canonical),
472
- "name": str(payload.get("name") or tag),
473
- # Notes travel into the state file and the MCP answer, so they are
474
- # bounded: a long changelog must not bloat either.
475
- "notes": str(payload.get("body") or "")[:4000],
476
- "published_at": str(payload.get("published_at") or ""),
477
- "html_url": str(payload.get("html_url") or ""),
478
- "prerelease": bool(payload.get("prerelease")),
479
- "assets": assets,
480
- "tarball_url": str(payload.get("tarball_url") or ""),
481
- "zipball_url": str(payload.get("zipball_url") or ""),
482
- }
483
-
484
-
485
- def _public_release(release: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
486
- """The release without its internal sort key, for JSON results."""
487
- if not release:
488
- return None
489
- return {key: value for key, value in release.items() if key != "parsed"}
490
-
491
-
492
- def fetch_latest_release(*, repo: str = REPO, token: str = "",
493
- prerelease: bool = False, timeout: float = HTTP_TIMEOUT,
494
- etag: str = "") -> Optional[Dict[str, Any]]:
495
- """The newest published release, or ``None`` when the request came back 304.
496
-
497
- ``prerelease`` asks the list endpoint for the newest release of any kind and
498
- picks the first one that carries a parseable version; the stable channel asks
499
- ``/releases/latest``, which already excludes drafts and pre-releases.
500
- """
501
- if prerelease:
502
- url = "%s/repos/%s/releases?per_page=20" % (API_ROOT, repo)
503
- else:
504
- url = "%s/repos/%s/releases/latest" % (API_ROOT, repo)
505
- headers = _api_headers(token)
506
- if etag:
507
- headers["If-None-Match"] = etag
508
- status, response_headers, body = http_get(url, headers=headers, timeout=timeout)
509
- if status == 304:
510
- return None
511
- if status in (403, 429):
512
- if response_headers.get("x-ratelimit-remaining") == "0" or status == 429:
513
- _raise_rate_limited(status, response_headers, body)
514
- if status == 404:
515
- raise UpdateError("no releases found in %s (HTTP 404)" % repo)
516
- if status != 200:
517
- raise UpdateError("GitHub answered HTTP %d for %s" % (status, _short(url)))
518
- try:
519
- payload = json.loads(body.decode("utf-8", "replace"))
520
- except Exception as exc:
521
- raise UpdateError("GitHub answered with invalid JSON: %s" % exc)
522
- if prerelease:
523
- if not isinstance(payload, list):
524
- raise UpdateError("the releases list was not a JSON array")
525
- for item in payload:
526
- if not isinstance(item, dict):
527
- continue
528
- release = _release_from_payload(item)
529
- if release is not None:
530
- return release
531
- raise UpdateError("no release in %s carries a version tag" % repo)
532
- if not isinstance(payload, dict):
533
- raise UpdateError("the latest-release answer was not a JSON object")
534
- release = _release_from_payload(payload)
535
- if release is None:
536
- raise UpdateError("release %s carries no parseable version tag"
537
- % payload.get("tag_name"))
538
- return release
539
-
540
-
541
- # ---------------------------------------------------------------------------
542
- # Assets
543
- # ---------------------------------------------------------------------------
544
-
545
- def checksum_asset_name(name: str) -> str:
546
- return name + ".sha256"
547
-
548
-
549
- def find_asset(release: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]:
550
- for asset in release.get("assets") or []:
551
- if asset.get("name") == name:
552
- return asset
553
- return None
554
-
555
-
556
- def choose_asset(release: Dict[str, Any], *, platform: Optional[str] = None) -> Optional[Dict[str, Any]]:
557
- """The best asset for this platform, in preference order.
558
-
559
- 1. ``mesh-payload-<version>.zip`` - the node payload, built by the release
560
- workflow, verified with its published ``.sha256``;
561
- 2. ``AntigravityMesh-Setup-<version>.exe`` - the Windows setup, unpacked with
562
- ``--unpack`` and applied from there (Windows only);
563
- 3. the release source archive - only when the operator allowed unverified
564
- payloads, because GitHub publishes no checksum for it.
565
- """
566
- version = str(release.get("version") or "")
567
- payload = find_asset(release, PAYLOAD_ASSET % version)
568
- if payload and payload.get("size", 0) > 0:
569
- return {"kind": "payload", "asset": payload, "name": payload["name"],
570
- "url": payload["url"], "size": payload["size"], "verified": True}
571
- name = platform or ("windows" if os.name == "nt" else sys.platform)
572
- if name.startswith("win"):
573
- setup = find_asset(release, SETUP_ASSET % version)
574
- if setup and setup.get("url"):
575
- return {"kind": "setup", "asset": setup, "name": setup["name"],
576
- "url": setup["url"], "size": setup["size"], "verified": True}
577
- tarball = str(release.get("tarball_url") or "")
578
- if tarball:
579
- return {"kind": "tarball", "asset": None, "name": "%s.tar.gz" % str(release.get("tag") or version),
580
- "url": tarball, "size": 0, "verified": False}
581
- return None
582
-
583
-
584
- def _checksum_for(release: Dict[str, Any], asset_name: str,
585
- *, timeout: float = HTTP_TIMEOUT) -> str:
586
- """The published SHA-256 of ``asset_name``, or "" when the release has none."""
587
- asset = find_asset(release, checksum_asset_name(asset_name))
588
- if asset and asset.get("url"):
589
- status, _headers, body = http_get(asset["url"], timeout=timeout, max_bytes=4096)
590
- if status == 200:
591
- match = _SHA256_RE.search(body.decode("utf-8", "replace"))
592
- if match:
593
- return match.group(1).lower()
594
- digest = ""
595
- for candidate in release.get("assets") or []:
596
- if candidate.get("name") == asset_name:
597
- digest = str(candidate.get("digest") or "")
598
- break
599
- if digest.lower().startswith("sha256:"):
600
- return digest.split(":", 1)[1].strip().lower()
601
- return ""
602
-
603
-
604
- # ---------------------------------------------------------------------------
605
- # Download
606
- # ---------------------------------------------------------------------------
607
-
608
- def download_to_file(url: str, destination: str, *, expected_sha256: str = "",
609
- timeout: float = DOWNLOAD_TIMEOUT, token: str = "",
610
- max_bytes: int = MAX_DOWNLOAD_BYTES) -> Dict[str, Any]:
611
- """Stream ``url`` into ``destination``, hashing as it goes.
612
-
613
- The file is written to ``destination + ".part"`` and only renamed into place
614
- once it is complete and verified, so an interrupted download can never be
615
- mistaken for a payload.
616
- """
617
- directory = os.path.dirname(destination)
618
- if directory:
619
- os.makedirs(directory, exist_ok=True)
620
- headers = {"User-Agent": USER_AGENT, "Accept": "application/octet-stream"}
621
- if token and "api.github.com" in url:
622
- headers["Authorization"] = "Bearer %s" % token
623
- request = urllib.request.Request(url, headers=headers, method="GET")
624
- digest = hashlib.sha256()
625
- written = 0
626
- partial = destination + ".part"
627
- try:
628
- with urllib.request.urlopen(request, timeout=timeout, context=_ssl_context()) as response:
629
- status = int(getattr(response, "status", 200) or 200)
630
- if status != 200:
631
- raise UpdateError("download failed: HTTP %d for %s" % (status, _short(url)))
632
- with open(partial, "wb") as handle:
633
- while True:
634
- block = response.read(1024 * 256)
635
- if not block:
636
- break
637
- written += len(block)
638
- if written > max_bytes:
639
- raise UpdateError("download exceeded %d bytes; refusing" % max_bytes)
640
- digest.update(block)
641
- handle.write(block)
642
- except urllib.error.HTTPError as exc:
643
- _remove(partial)
644
- raise UpdateError("download failed: HTTP %d for %s" % (exc.code, _short(url)))
645
- except UpdateError:
646
- _remove(partial)
647
- raise
648
- except Exception as exc:
649
- _remove(partial)
650
- raise UpdateError("download failed: %s" % exc)
651
- actual = digest.hexdigest().lower()
652
- if expected_sha256 and actual != expected_sha256.lower():
653
- _remove(partial)
654
- raise UpdateError("SHA-256 mismatch: published %s, downloaded %s"
655
- % (expected_sha256[:16], actual[:16]))
656
- os.replace(partial, destination)
657
- return {"path": destination, "size": written, "sha256": actual,
658
- "verified": bool(expected_sha256)}
659
-
660
-
661
- def _remove(path: str) -> None:
662
- try:
663
- if os.path.isdir(path):
664
- shutil.rmtree(path, ignore_errors=True)
665
- elif os.path.exists(path):
666
- os.remove(path)
667
- except OSError:
668
- pass
669
-
670
-
671
- # ---------------------------------------------------------------------------
672
- # Staging a payload
673
- # ---------------------------------------------------------------------------
674
-
675
- def verify_payload(root: str) -> None:
676
- """Raise unless ``root`` holds a complete node payload."""
677
- missing = [relative for relative in REQUIRED_PATHS
678
- if not os.path.isfile(os.path.join(root, *relative.split("/")))]
679
- if missing:
680
- raise UpdateError("the payload is incomplete: missing %s" % ", ".join(missing))
681
- if not any(os.path.isdir(os.path.join(root, name)) for name in PAYLOAD_DIRS):
682
- raise UpdateError("the payload holds none of %s" % ", ".join(PAYLOAD_DIRS))
683
-
684
-
685
- def _safe_extract_zip(archive: str, destination: str) -> None:
686
- """Extract ``archive``, refusing absolute paths and ``..`` escapes.
687
-
688
- Members are written one by one rather than through ``extractall``: archive
689
- entry names are normalised from Windows separators first. ``zipfile`` treats a
690
- backslash as an ordinary character on POSIX, so an archive built by
691
- ``Compress-Archive`` would otherwise unpack into files literally named
692
- ``core\\agent.py`` and the node would never find its own code.
693
- """
694
- with zipfile.ZipFile(archive) as bundle:
695
- for member in bundle.infolist():
696
- name = member.filename.replace("\\", "/")
697
- if name.startswith("/") or ".." in name.split("/") or re.match(r"^[A-Za-z]:", name):
698
- raise UpdateError("the payload archive contains an unsafe path: %s" % member.filename)
699
- target = os.path.join(destination, *[part for part in name.split("/") if part])
700
- if member.is_dir() or not name.strip("/"):
701
- os.makedirs(target, exist_ok=True)
702
- continue
703
- os.makedirs(os.path.dirname(target) or destination, exist_ok=True)
704
- with bundle.open(member) as source, open(target, "wb") as handle:
705
- shutil.copyfileobj(source, handle, 1024 * 256)
706
-
707
-
708
- def _safe_extract_tar(archive: str, destination: str) -> None:
709
- with tarfile.open(archive, "r:*") as bundle:
710
- for member in bundle.getmembers():
711
- name = member.name.replace("\\", "/")
712
- if name.startswith("/") or ".." in name.split("/") or member.islnk() or member.issym():
713
- raise UpdateError("the payload archive contains an unsafe entry: %s" % member.name)
714
- try: # Python 3.12+: refuse device files and the like
715
- bundle.extractall(destination, filter="data")
716
- except TypeError:
717
- bundle.extractall(destination)
718
-
719
-
720
- def _strip_single_root(directory: str) -> str:
721
- """Return the payload root inside ``directory``.
722
-
723
- GitHub's source archives wrap everything in one ``<repo>-<tag>/`` directory,
724
- while our own ``mesh-payload`` zip has ``core/`` at the top level. Both are
725
- accepted by looking for the level that actually holds ``core``.
726
- """
727
- if os.path.isdir(os.path.join(directory, "core")):
728
- return directory
729
- entries = [name for name in os.listdir(directory) if not name.startswith(".")]
730
- for name in entries:
731
- inner = os.path.join(directory, name)
732
- if os.path.isdir(inner) and os.path.isdir(os.path.join(inner, "core")):
733
- return inner
734
- return directory
735
-
736
-
737
- def _no_window_flags() -> int:
738
- """CREATE_NO_WINDOW for a console child on Windows, 0 elsewhere.
739
-
740
- The agent itself runs in a hidden console (the Windows launcher starts it with
741
- ``WshShell.Run ..., 0``), but a child that does not inherit that console gets a
742
- NEW one - and a new console is a window on the user's desktop. The updater
743
- starts powershell.exe, taskkill.exe and schtasks.exe, so without this flag an
744
- update paints windows nobody asked for.
745
- """
746
- return 0x08000000 if os.name == "nt" else 0
747
-
748
-
749
- def _run_capture(argv: Sequence[str], *, cwd: Optional[str] = None,
750
- timeout: float = 180.0,
751
- env: Optional[Dict[str, str]] = None) -> Tuple[int, str]:
752
- try:
753
- completed = subprocess.run(list(argv), cwd=cwd, capture_output=True,
754
- timeout=timeout, check=False, env=env,
755
- creationflags=_no_window_flags())
756
- except FileNotFoundError as exc:
757
- return (127, str(exc))
758
- except subprocess.TimeoutExpired:
759
- return (124, "timed out after %.0fs" % timeout)
760
- output = b"\n".join(part for part in (completed.stdout, completed.stderr) if part)
761
- return (completed.returncode, output.decode("utf-8", "replace"))
762
-
763
-
764
- _UNPACK_PATH_RE = re.compile(r"^unpacked\s+\S+\s+to\s+(.+?)\s*$", re.M)
765
-
766
-
767
- def _unpack_setup(exe_path: str, workdir: str, version: str = "") -> str:
768
- """Unpack a downloaded setup executable and return its payload root.
769
-
770
- ``AntigravityMesh-Setup-<version>.exe --unpack`` prints
771
- ``unpacked <version> to <path>`` and stops, which is the authoritative answer;
772
- the paths it defaults to are only used when that line cannot be parsed.
773
- """
774
- environment = dict(os.environ)
775
- setup_root = os.path.join(workdir, "setup")
776
- environment["MESH_SETUP_DIR"] = setup_root
777
- logger.info("Unpacking %s", os.path.basename(exe_path))
778
- code, output = _run_capture([exe_path, "--unpack"], timeout=300.0, env=environment)
779
- if code != 0:
780
- raise UpdateError("the setup executable could not unpack itself (exit %d): %s"
781
- % (code, output.strip()[:300]))
782
- candidates: List[str] = []
783
- match = _UNPACK_PATH_RE.search(output)
784
- if match:
785
- candidates.append(match.group(1).strip().strip('"'))
786
- if version:
787
- candidates.append(os.path.join(setup_root, version))
788
- candidates.append(setup_root)
789
- for candidate in candidates:
790
- if os.path.isdir(os.path.join(candidate, "core")):
791
- return candidate
792
- if os.path.isdir(setup_root):
793
- for entry in sorted(os.listdir(setup_root), reverse=True):
794
- candidate = os.path.join(setup_root, entry)
795
- if os.path.isdir(os.path.join(candidate, "core")):
796
- return candidate
797
- raise UpdateError("the setup executable unpacked no payload: %s" % output.strip()[:300])
798
-
799
-
800
- def stage_payload(release: Dict[str, Any], *, config: Dict[str, Any],
801
- workdir: Optional[str] = None) -> Dict[str, Any]:
802
- """Download and unpack the release payload; returns the staging description.
803
-
804
- The returned ``root`` is a directory that :func:`verify_payload` accepted.
805
- """
806
- asset = choose_asset(release)
807
- if asset is None:
808
- raise UpdateError("release %s carries no usable asset" % release.get("tag"))
809
- if not asset["verified"] and not config.get("allow_unverified"):
810
- raise UpdateError(
811
- "release %s has no mesh-payload asset and no checksum; refusing the "
812
- "source-archive fallback (set MESH_UPDATE_ALLOW_UNVERIFIED=1 to accept it)"
813
- % release.get("tag"))
814
- workdir = workdir or tempfile.mkdtemp(prefix="mesh-update-")
815
- os.makedirs(workdir, exist_ok=True)
816
- expected = _checksum_for(release, asset["name"]) if asset["asset"] else ""
817
- if asset["verified"] and not expected and not config.get("allow_unverified"):
818
- raise UpdateError("no published SHA-256 for %s; refusing to install an "
819
- "unverifiable payload (set MESH_UPDATE_ALLOW_UNVERIFIED=1 "
820
- "to accept it)" % asset["name"])
821
- target = os.path.join(workdir, os.path.basename(asset["name"]))
822
- download = download_to_file(asset["url"], target, expected_sha256=expected,
823
- token=str(config.get("token") or ""))
824
- root = workdir
825
- if asset["kind"] == "payload":
826
- _safe_extract_zip(target, workdir)
827
- root = _strip_single_root(workdir)
828
- elif asset["kind"] == "setup":
829
- root = _unpack_setup(target, workdir, str(release.get("version") or ""))
830
- else:
831
- _safe_extract_tar(target, workdir)
832
- root = _strip_single_root(workdir)
833
- verify_payload(root)
834
- return {"root": root, "workdir": workdir, "asset": asset["name"],
835
- "kind": asset["kind"], "size": download["size"],
836
- "sha256": download["sha256"], "verified": download["verified"],
837
- "archive": target}
838
-
839
-
840
- # ---------------------------------------------------------------------------
841
- # Applying a payload
842
- # ---------------------------------------------------------------------------
843
-
844
- def _move_with_retry(source: str, destination: str, attempts: int = 5) -> None:
845
- """Move ``source`` onto ``destination``, retrying Windows handle contention.
846
-
847
- A running Python may still hold ``__pycache__`` files open for a moment, and
848
- an antivirus scanner can hold a fresh ``.py`` for longer; both make a
849
- directory rename fail with a sharing violation that clears by itself.
850
- """
851
- last: Optional[BaseException] = None
852
- for attempt in range(attempts):
853
- try:
854
- shutil.move(source, destination)
855
- return
856
- except OSError as exc:
857
- last = exc
858
- time.sleep(0.4 * (attempt + 1))
859
- raise UpdateError("could not move %s into place: %s" % (source, last))
860
-
861
-
862
- def _prune_pycache(root: str) -> int:
863
- """Delete stale bytecode below ``root``; returns how many caches were removed.
864
-
865
- A payload built from a working tree can carry ``__pycache__`` directories
866
- whose timestamps match the sources they were compiled from, so Python would
867
- happily run the OLD bytecode under the NEW file names. Removing them makes
868
- the next import compile the sources that were actually installed.
869
- """
870
- removed = 0
871
- for current, directories, _files in os.walk(root):
872
- if "__pycache__" in directories:
873
- target = os.path.join(current, "__pycache__")
874
- shutil.rmtree(target, ignore_errors=True)
875
- directories.remove("__pycache__")
876
- removed += 1
877
- return removed
878
-
879
-
880
- def _backup_destination(config: Dict[str, Any], from_version: str) -> str:
881
- stamp = time.strftime("%Y%m%d-%H%M%S")
882
- safe = re.sub(r"[^0-9A-Za-z.\-_]", "_", from_version or "unknown")
883
- return os.path.join(str(config["backup_root"]), "%s-%s" % (safe, stamp))
884
-
885
-
886
- def prune_backups(backup_root: str, keep: int) -> int:
887
- """Keep the newest ``keep`` backups; returns how many were deleted."""
888
- try:
889
- entries = sorted((os.path.join(backup_root, name) for name in os.listdir(backup_root)
890
- if os.path.isdir(os.path.join(backup_root, name))),
891
- key=lambda path: os.path.getmtime(path), reverse=True)
892
- except OSError:
893
- return 0
894
- deleted = 0
895
- for path in entries[max(0, keep):]:
896
- shutil.rmtree(path, ignore_errors=True)
897
- deleted += 1
898
- return deleted
899
-
900
-
901
- def apply_payload(payload_root: str, install_dir: str, *, config: Dict[str, Any],
902
- from_version: str = "", to_version: str = "") -> Dict[str, Any]:
903
- """Replace the node payload in ``install_dir`` with the staged one.
904
-
905
- Only :data:`PAYLOAD_DIRS` and :data:`PAYLOAD_FILES` are touched. Every
906
- replaced name is first moved into a timestamped backup and the operation is
907
- journalled before it starts, so :func:`recover_pending` can undo it after a
908
- crash or a power cut.
909
- """
910
- verify_payload(payload_root)
911
- install_dir = os.path.abspath(install_dir)
912
- if not os.path.isdir(install_dir):
913
- raise UpdateError("the install directory does not exist: %s" % install_dir)
914
- if os.path.abspath(payload_root) == install_dir:
915
- raise UpdateError("refusing to update a directory onto itself")
916
- names = [name for name in PAYLOAD_DIRS if os.path.isdir(os.path.join(payload_root, name))]
917
- files = [name for name in PAYLOAD_FILES if os.path.isfile(os.path.join(payload_root, name))]
918
- if not names:
919
- raise UpdateError("the payload holds no %s directory" % "/".join(PAYLOAD_DIRS))
920
- backup = _backup_destination(config, from_version)
921
- os.makedirs(backup, exist_ok=True)
922
- journal = {
923
- "state": "applying",
924
- "from": from_version,
925
- "to": to_version,
926
- "install_dir": install_dir,
927
- "backup": backup,
928
- "names": names + files,
929
- "started_at": now_iso(),
930
- "pid": os.getpid(),
931
- }
932
- _write_json_atomic(str(config["journal_file"]), journal)
933
- file_log("applying %s -> %s (backup %s)" % (from_version or "?", to_version or "?",
934
- backup), str(config.get("log_file")))
935
-
936
- moved: List[Tuple[str, str]] = [] # (name, backup path)
937
- placed: List[str] = []
938
- try:
939
- for name in names + files:
940
- source = os.path.join(payload_root, name)
941
- target = os.path.join(install_dir, name)
942
- if os.path.exists(target):
943
- saved = os.path.join(backup, name)
944
- _move_with_retry(target, saved)
945
- moved.append((name, saved))
946
- _move_with_retry(source, target)
947
- placed.append(name)
948
- except BaseException as exc:
949
- # Put back what was moved, newest first, so a half-applied update does not
950
- # survive this call. Failures here are reported, not swallowed: the
951
- # journal stays "applying" and recover_pending retries on the next start.
952
- for name in reversed(placed):
953
- _remove(os.path.join(install_dir, name))
954
- for name, saved in reversed(moved):
955
- try:
956
- _move_with_retry(saved, os.path.join(install_dir, name))
957
- except UpdateError as restore_exc:
958
- file_log("ROLLBACK FAILED for %s: %s" % (name, restore_exc),
959
- str(config.get("log_file")))
960
- raise UpdateError("the update could not be applied (%s); the previous version "
961
- "was restored" % exc)
962
-
963
- journal["state"] = "swapped"
964
- journal["swapped_at"] = now_iso()
965
- _write_json_atomic(str(config["journal_file"]), journal)
966
-
967
- removed_pycache = sum(_prune_pycache(os.path.join(install_dir, name)) for name in names)
968
- marker = os.path.join(install_dir, "VERSION")
969
- try:
970
- with open(marker, "w", encoding="utf-8") as handle:
971
- handle.write("%s\n" % (to_version or local_version(payload_root)))
972
- except OSError as exc:
973
- logger.debug("could not write %s: %s", marker, exc)
974
-
975
- journal["state"] = "applied"
976
- journal["applied_at"] = now_iso()
977
- _write_json_atomic(str(config["journal_file"]), journal)
978
- kept = max(0, int(config.get("keep_backups", 0)))
979
- prune_backups(str(config["backup_root"]), kept)
980
- file_log("applied %s -> %s (%d caches cleared)"
981
- % (from_version or "?", to_version or "?", removed_pycache),
982
- str(config.get("log_file")))
983
- return {"applied": True, "from": from_version, "to": to_version,
984
- "backup": backup, "replaced": names + files,
985
- "pycache_removed": removed_pycache}
986
-
987
-
988
- def recover_pending(*, config: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
989
- """Finish or undo an update that was interrupted, at agent start.
990
-
991
- Returns a description when something was done, ``None`` when the journal was
992
- clean. A journal left at ``applying`` means the swap did not finish: the
993
- backup is restored, which is the only safe direction - a node on the old
994
- version always works, a node with half of two versions may not.
995
- """
996
- settings = config or config_from_env()
997
- journal = _read_json(str(settings["journal_file"]))
998
- if not journal:
999
- return None
1000
- state = str(journal.get("state") or "")
1001
- install_dir = str(journal.get("install_dir") or settings["install_dir"])
1002
- result: Dict[str, Any] = {"state": state, "to": journal.get("to"),
1003
- "from": journal.get("from"), "install_dir": install_dir}
1004
- if state == "applying":
1005
- backup = str(journal.get("backup") or "")
1006
- restored: List[str] = []
1007
- for name in journal.get("names") or []:
1008
- saved = os.path.join(backup, str(name))
1009
- if not os.path.exists(saved):
1010
- continue
1011
- target = os.path.join(install_dir, str(name))
1012
- _remove(target)
1013
- try:
1014
- _move_with_retry(saved, target)
1015
- restored.append(str(name))
1016
- except UpdateError as exc:
1017
- result["error"] = "could not restore %s: %s" % (name, exc)
1018
- file_log("RECOVERY FAILED: %s" % result["error"], str(settings.get("log_file")))
1019
- return result
1020
- result["action"] = "rolled back"
1021
- result["restored"] = restored
1022
- file_log("recovered an interrupted update: rolled back %s to %s"
1023
- % (journal.get("to"), journal.get("from") or local_version(install_dir)),
1024
- str(settings.get("log_file")))
1025
- elif state == "swapped":
1026
- # The tree is consistent; only the post-swap bookkeeping is missing.
1027
- result["action"] = "completed"
1028
- try:
1029
- with open(os.path.join(install_dir, "VERSION"), "w", encoding="utf-8") as handle:
1030
- handle.write("%s\n" % (journal.get("to") or local_version(install_dir)))
1031
- except OSError:
1032
- pass
1033
- file_log("completed an interrupted update to %s" % journal.get("to"),
1034
- str(settings.get("log_file")))
1035
- else:
1036
- result["action"] = "cleared"
1037
- _remove(str(settings["journal_file"]))
1038
- return result
1039
-
1040
-
1041
- # ---------------------------------------------------------------------------
1042
- # Restarting the node
1043
- # ---------------------------------------------------------------------------
1044
-
1045
- def _pid_alive(pid: int) -> bool:
1046
- if pid <= 0:
1047
- return False
1048
- if os.name == "nt":
1049
- import ctypes
1050
- from ctypes import wintypes
1051
- process_query_limited_information = 0x1000
1052
- still_active = 259
1053
- kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
1054
- kernel32.OpenProcess.restype = wintypes.HANDLE
1055
- kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
1056
- kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
1057
- handle = kernel32.OpenProcess(process_query_limited_information, False, int(pid))
1058
- if not handle:
1059
- return False # gone, or access denied: both mean "not ours anymore"
1060
- try:
1061
- code = wintypes.DWORD()
1062
- if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
1063
- return False
1064
- return code.value == still_active
1065
- finally:
1066
- kernel32.CloseHandle(handle)
1067
- try:
1068
- os.kill(int(pid), 0)
1069
- except ProcessLookupError:
1070
- return False
1071
- except PermissionError:
1072
- return True
1073
- except OSError:
1074
- return False
1075
- return True
1076
-
1077
-
1078
- def _heartbeat_state(path: Optional[str] = None) -> Dict[str, Any]:
1079
- return _read_json(path or HEARTBEAT_FILE) or {}
1080
-
1081
-
1082
- def running_agent_pid(config: Optional[Dict[str, Any]] = None) -> int:
1083
- """The pid of the agent that currently owns this node, or 0.
1084
-
1085
- A fresh heartbeat naming a live process is the only accepted answer: the pid
1086
- must not be reused by something unrelated, and a node that is not running
1087
- needs no stop at all.
1088
- """
1089
- settings = config or config_from_env()
1090
- state = _heartbeat_state(settings.get("heartbeat_file"))
1091
- try:
1092
- pid = int(state.get("pid") or 0)
1093
- stamp = float(state.get("ts") or 0.0)
1094
- except Exception:
1095
- return 0
1096
- if not pid or pid == os.getpid():
1097
- return 0
1098
- if (time.time() - stamp) > 300.0:
1099
- return 0
1100
- return pid if _pid_alive(pid) else 0
1101
-
1102
-
1103
- def stop_agent(pid: int, *, config: Optional[Dict[str, Any]] = None,
1104
- grace: float = 15.0) -> Dict[str, Any]:
1105
- """Ask a running agent to exit, then insist.
1106
-
1107
- A node whose old agent keeps running is a node that never actually updates:
1108
- the new process waits for the instance lock the old one holds and the tunnel
1109
- keeps saying the old version is current. A graceful signal comes first (the
1110
- agent writes its clean-stop marker and releases everything); a process that
1111
- ignores it for ``grace`` seconds is terminated.
1112
- """
1113
- if pid <= 0 or pid == os.getpid() or not _pid_alive(pid):
1114
- return {"stopped": False, "reason": "nothing to stop", "pid": pid}
1115
- if os.name == "nt":
1116
- code, _output = _run_capture(["taskkill", "/PID", str(pid)], timeout=30.0)
1117
- if code == 0:
1118
- waited = _wait_for_exit(pid, 10.0)
1119
- if waited:
1120
- return {"stopped": True, "pid": pid, "method": "taskkill"}
1121
- code, output = _run_capture(["taskkill", "/T", "/F", "/PID", str(pid)], timeout=60.0)
1122
- alive = _pid_alive(pid)
1123
- return {"stopped": not alive, "pid": pid, "method": "taskkill /T /F",
1124
- "exit_code": code, "detail": output.strip()[-200:]}
1125
- number = getattr(signal, "SIGTERM", 15)
1126
- try:
1127
- os.kill(pid, number)
1128
- except OSError as exc:
1129
- return {"stopped": False, "pid": pid, "method": "SIGTERM", "error": str(exc)}
1130
- if _wait_for_exit(pid, grace):
1131
- return {"stopped": True, "pid": pid, "method": "SIGTERM"}
1132
- try:
1133
- os.kill(pid, getattr(signal, "SIGKILL", 9))
1134
- except OSError as exc:
1135
- return {"stopped": False, "pid": pid, "method": "SIGKILL", "error": str(exc)}
1136
- return {"stopped": _wait_for_exit(pid, 10.0), "pid": pid, "method": "SIGKILL"}
1137
-
1138
-
1139
- def _wait_for_exit(pid: int, timeout: float) -> bool:
1140
- deadline = time.time() + max(0.0, timeout)
1141
- while time.time() < deadline:
1142
- if not _pid_alive(pid):
1143
- return True
1144
- time.sleep(0.5)
1145
- return not _pid_alive(pid)
1146
-
1147
-
1148
- def wait_for_node(heartbeat_file: str, *, previous_pid: int = 0,
1149
- after: float = 0.0, timeout: float = 45.0) -> bool:
1150
- """True once a heartbeat newer than ``after`` comes from a different process."""
1151
- deadline = time.time() + max(1.0, timeout)
1152
- while time.time() < deadline:
1153
- state = _heartbeat_state(heartbeat_file)
1154
- try:
1155
- stamp = float(state.get("ts") or 0.0)
1156
- except Exception:
1157
- stamp = 0.0
1158
- pid = int(state.get("pid") or 0)
1159
- if stamp > after and pid and pid != previous_pid:
1160
- return True
1161
- time.sleep(2.0)
1162
- return False
1163
-
1164
-
1165
- def _detached_flags() -> Dict[str, Any]:
1166
- if os.name == "nt":
1167
- detached = 0x00000008 # DETACHED_PROCESS
1168
- new_group = 0x00000200 # CREATE_NEW_PROCESS_GROUP
1169
- no_window = 0x08000000 # CREATE_NO_WINDOW
1170
- return {"creationflags": detached | new_group | no_window}
1171
- return {"start_new_session": True}
1172
-
1173
-
1174
- def _agent_log_handle(config: Dict[str, Any]):
1175
- path = os.path.join(os.path.dirname(str(config.get("log_file") or LOG_FILE)), "agent.log")
1176
- try:
1177
- return open(path, "a", encoding="utf-8")
1178
- except OSError:
1179
- return subprocess.DEVNULL
1180
-
1181
-
1182
- def _start_agent_directly(install_dir: str, config: Dict[str, Any]) -> Dict[str, Any]:
1183
- """Start ``python -m core.agent`` detached, the way a bare install would."""
1184
- handle = _agent_log_handle(config)
1185
- argv = [sys.executable or "python", "-u", "-m", "core.agent"]
1186
- kwargs: Dict[str, Any] = {"cwd": install_dir, "stdout": handle,
1187
- "stderr": subprocess.STDOUT}
1188
- kwargs.update(_detached_flags())
1189
- try:
1190
- process = subprocess.Popen(argv, **kwargs)
1191
- except Exception as exc:
1192
- return {"ok": False, "method": "direct", "error": str(exc)}
1193
- finally:
1194
- if handle is not subprocess.DEVNULL:
1195
- try:
1196
- handle.close()
1197
- except Exception:
1198
- pass
1199
- return {"ok": True, "method": "direct", "pid": process.pid}
1200
-
1201
-
1202
- def _restart_windows(install_dir: str, config: Dict[str, Any],
1203
- previous_pid: int) -> Dict[str, Any]:
1204
- watchdog = os.path.join(install_dir, *WATCHDOG.split("/"))
1205
- if not os.path.isfile(watchdog):
1206
- return _start_agent_directly(install_dir, config)
1207
- heartbeat = str(config.get("heartbeat_file") or HEARTBEAT_FILE)
1208
- started = time.time()
1209
- attempts = [5, 0]
1210
- last: Dict[str, Any] = {}
1211
- for grace in attempts:
1212
- argv = ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
1213
- "-WindowStyle", "Hidden", "-File", watchdog,
1214
- "-GraceSeconds", str(grace), "-Quiet"]
1215
- code, output = _run_capture(argv, cwd=install_dir, timeout=180.0)
1216
- last = {"ok": code == 0, "method": "watchdog", "grace": grace,
1217
- "exit_code": code, "output": output.strip()[-300:]}
1218
- file_log("watchdog (grace %ss) exited %d" % (grace, code),
1219
- str(config.get("log_file")))
1220
- if wait_for_node(heartbeat, previous_pid=previous_pid, after=started, timeout=30.0):
1221
- last["ok"] = True
1222
- last["verified"] = True
1223
- return last
1224
- # The watchdog can answer "online" from the gateway before the closed tunnel
1225
- # is noticed; starting the agent directly is the deterministic last resort.
1226
- direct = _start_agent_directly(install_dir, config)
1227
- direct["watchdog"] = last
1228
- direct["verified"] = wait_for_node(heartbeat, previous_pid=previous_pid,
1229
- after=started, timeout=30.0)
1230
- return direct
1231
-
1232
-
1233
- def _restart_posix(install_dir: str, config: Dict[str, Any],
1234
- previous_pid: int) -> Dict[str, Any]:
1235
- heartbeat = str(config.get("heartbeat_file") or HEARTBEAT_FILE)
1236
- started = time.time()
1237
- home = os.path.expanduser("~")
1238
- if sys.platform == "darwin":
1239
- plist = os.path.join(home, "Library", "LaunchAgents", LAUNCHD_LABEL + ".plist")
1240
- if os.path.isfile(plist) and shutil.which("launchctl"):
1241
- target = "gui/%d/%s" % (os.getuid(), LAUNCHD_LABEL)
1242
- code, output = _run_capture(["launchctl", "kickstart", "-k", target], timeout=60.0)
1243
- if code == 0:
1244
- verified = wait_for_node(heartbeat, previous_pid=previous_pid,
1245
- after=started, timeout=45.0)
1246
- if verified:
1247
- return {"ok": True, "method": "launchctl", "verified": True}
1248
- return _start_agent_directly(install_dir, config)
1249
- return _start_agent_directly(install_dir, config)
1250
- unit = os.path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT)
1251
- if os.path.isfile(unit) and shutil.which("systemctl"):
1252
- code, output = _run_capture(["systemctl", "--user", "restart", SYSTEMD_UNIT], timeout=60.0)
1253
- if code == 0:
1254
- verified = wait_for_node(heartbeat, previous_pid=previous_pid,
1255
- after=started, timeout=45.0)
1256
- if verified:
1257
- return {"ok": True, "method": "systemctl", "verified": True}
1258
- else:
1259
- file_log("systemctl --user restart %s exited %d: %s"
1260
- % (SYSTEMD_UNIT, code, output.strip()[-200:]),
1261
- str(config.get("log_file")))
1262
- return _start_agent_directly(install_dir, config)
1263
-
1264
-
1265
- def restart_node(install_dir: str, *, config: Optional[Dict[str, Any]] = None,
1266
- previous_pid: int = 0) -> Dict[str, Any]:
1267
- """Bring the agent back through whatever supervises it."""
1268
- settings = config or config_from_env()
1269
- if os.name == "nt":
1270
- return _restart_windows(install_dir, settings, previous_pid)
1271
- return _restart_posix(install_dir, settings, previous_pid)
1272
-
1273
-
1274
- #: Set by :mod:`core.agent` at startup. The updater calls it after a successful
1275
- #: apply so the process that must be replaced stops itself; a caller that manages
1276
- #: the node externally (a CLI, a test) leaves it unset.
1277
- _RESTART_HOOK: Optional[Any] = None
1278
-
1279
-
1280
- def set_restart_hook(hook: Optional[Any]) -> None:
1281
- """Register the callable that stops the running agent after an update."""
1282
- global _RESTART_HOOK
1283
- _RESTART_HOOK = hook
1284
-
1285
-
1286
- def _notify_restart() -> bool:
1287
- hook = _RESTART_HOOK
1288
- if hook is None:
1289
- return False
1290
- try:
1291
- hook()
1292
- return True
1293
- except Exception as exc: # a broken hook must not undo an update
1294
- logger.warning("the restart hook failed: %s", exc)
1295
- return False
1296
-
1297
-
1298
- def spawn_restart_helper(install_dir: str, *, config: Dict[str, Any],
1299
- pid: Optional[int] = None, wait_seconds: Optional[float] = None) -> Dict[str, Any]:
1300
- """Start a detached helper that brings the node back on the new version.
1301
-
1302
- The helper is a separate process on purpose. It must outlive the process it
1303
- replaces, wait for the instance lock and the tunnel to be released, and - when
1304
- the update was applied by a CLI or by the Windows Scheduled Task while the old
1305
- agent kept running - stop that agent, which would otherwise hold the lock
1306
- forever and leave the node on the old code.
1307
-
1308
- ``pid`` defaults to the agent that currently owns the node, or to this process
1309
- when the node is not running (an in-process apply).
1310
- """
1311
- self_pid = os.getpid()
1312
- target = int(pid) if pid is not None else (running_agent_pid(config) or self_pid)
1313
- foreign = target not in (0, self_pid)
1314
- if wait_seconds is None:
1315
- # Our own process stops itself within seconds once asked; a foreign agent
1316
- # has to be asked by the helper, so a long wait would only delay the node.
1317
- wait_seconds = 25.0 if foreign else 120.0
1318
- argv = [sys.executable or "python", "-m", "core.updater",
1319
- "--restart-helper", "--pid", str(target),
1320
- "--dir", install_dir, "--wait", str(float(wait_seconds))]
1321
- handle = _agent_log_handle(config)
1322
- kwargs: Dict[str, Any] = {"cwd": install_dir, "stdout": handle,
1323
- "stderr": subprocess.STDOUT}
1324
- kwargs.update(_detached_flags())
1325
- try:
1326
- process = subprocess.Popen(argv, **kwargs)
1327
- except Exception as exc:
1328
- return {"ok": False, "error": str(exc), "target_pid": target}
1329
- finally:
1330
- if handle is not subprocess.DEVNULL:
1331
- try:
1332
- handle.close()
1333
- except Exception:
1334
- pass
1335
- file_log("restart helper started (pid %s) for node pid %s (foreign=%s)"
1336
- % (process.pid, target, foreign), str(config.get("log_file")))
1337
- return {"ok": True, "pid": process.pid, "target_pid": target, "foreign": foreign}
1338
-
1339
-
1340
- def restart_helper_main(pid: int, install_dir: str, wait_seconds: float = 120.0) -> int:
1341
- """The ``--restart-helper`` entry point: wait for ``pid``, then restart."""
1342
- config = config_from_env(install_dir=install_dir)
1343
- deadline = time.time() + max(5.0, wait_seconds)
1344
- while time.time() < deadline and _pid_alive(pid):
1345
- time.sleep(1.0)
1346
- if pid > 0 and pid != os.getpid() and _pid_alive(pid):
1347
- stopped = stop_agent(pid, config=config)
1348
- file_log("restart helper stopped the old agent: %s" % json.dumps(stopped, sort_keys=True),
1349
- str(config.get("log_file")))
1350
- # A short grace period lets the OS release the instance lock and the gateway
1351
- # notice the closed tunnel before the watchdog is asked whether the node is up.
1352
- time.sleep(3.0)
1353
- result = restart_node(install_dir, config=config, previous_pid=pid)
1354
- file_log("restart helper result: %s" % json.dumps(result, sort_keys=True),
1355
- str(config.get("log_file")))
1356
- return 0 if result.get("ok") else 3
1357
-
1358
-
1359
- # ---------------------------------------------------------------------------
1360
- # The public operations: status, check, apply
1361
- # ---------------------------------------------------------------------------
1362
-
1363
- def status(*, config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1364
- """Local state only: no network, no side effects."""
1365
- settings = config or config_from_env()
1366
- install_dir = str(settings["install_dir"])
1367
- state = _read_json(str(settings["state_file"])) or {}
1368
- journal = _read_json(str(settings["journal_file"])) or {}
1369
- latest = state.get("latest") if isinstance(state.get("latest"), dict) else None
1370
- return {
1371
- "ok": True,
1372
- "current": local_version(install_dir),
1373
- "install_dir": install_dir,
1374
- "channel": settings["channel"],
1375
- "check_enabled": bool(settings["enabled"]),
1376
- "auto": bool(settings["auto"]),
1377
- "interval": int(settings["interval"]),
1378
- "last_check": state.get("last_check") or "",
1379
- "last_result": state.get("last_result") or "",
1380
- "last_applied": state.get("last_applied") or "",
1381
- "latest": (latest or {}).get("version") if latest else "",
1382
- "latest_url": (latest or {}).get("html_url") if latest else "",
1383
- "update_available": state.get("update_available") if isinstance(
1384
- state.get("update_available"), bool) else None,
1385
- "pending_journal": journal.get("state") or "",
1386
- "log_file": str(settings["log_file"]),
1387
- }
1388
-
1389
-
1390
- def check_for_update(*, config: Optional[Dict[str, Any]] = None, force: bool = False,
1391
- offline: bool = False) -> Dict[str, Any]:
1392
- """Ask GitHub for the newest release and compare it with the installed version.
1393
-
1394
- The answer is cached in the state file for ``MESH_UPDATE_CHECK_INTERVAL``:
1395
- an unauthenticated GitHub client is allowed 60 requests per hour, and a fleet
1396
- of nodes polling every minute would spend that on nothing.
1397
- """
1398
- settings = config or config_from_env()
1399
- install_dir = str(settings["install_dir"])
1400
- current = local_version(install_dir)
1401
- state = _read_json(str(settings["state_file"])) or {}
1402
- now = time.time()
1403
-
1404
- def cached_result(reason: str, *, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1405
- latest = state.get("latest") if isinstance(state.get("latest"), dict) else None
1406
- result = {
1407
- "ok": True,
1408
- "current": current,
1409
- "latest": (latest or {}).get("version") or current,
1410
- "update_available": bool(latest and is_newer(latest.get("version"), current)),
1411
- "reason": reason,
1412
- "cached": True,
1413
- "checked_at": state.get("last_check") or "",
1414
- "release": latest,
1415
- "asset": None,
1416
- "channel": settings["channel"],
1417
- "auto": bool(settings["auto"]),
1418
- "install_dir": install_dir,
1419
- }
1420
- if latest:
1421
- asset = choose_asset(latest)
1422
- result["asset"] = None if asset is None else {
1423
- "name": asset["name"], "kind": asset["kind"], "size": asset["size"],
1424
- "verified": asset["verified"]}
1425
- result["release"] = _public_release(latest)
1426
- if extra:
1427
- result.update(extra)
1428
- return result
1429
-
1430
- if not settings["enabled"]:
1431
- return cached_result("checking is disabled (MESH_UPDATE_CHECK=0)")
1432
- if offline:
1433
- return cached_result("offline: cached state only")
1434
- last_check = float(state.get("last_check_epoch") or 0.0)
1435
- if not force and last_check and (now - last_check) < int(settings["interval"]):
1436
- return cached_result("checked %d s ago; next check in %d s"
1437
- % (now - last_check, int(settings["interval"] - (now - last_check))))
1438
-
1439
- try:
1440
- release = fetch_latest_release(repo=str(settings["repo"]),
1441
- token=str(settings["token"] or ""),
1442
- prerelease=bool(settings["prerelease"]))
1443
- except RateLimited as exc:
1444
- state.update({"last_check": now_iso(), "last_check_epoch": now,
1445
- "last_result": "rate limited"})
1446
- _write_json_atomic(str(settings["state_file"]), state)
1447
- return cached_result("GitHub rate limit: %s" % exc)
1448
- except UpdateError as exc:
1449
- state.update({"last_check_epoch": now, "last_result": "check failed: %s" % exc})
1450
- _write_json_atomic(str(settings["state_file"]), state)
1451
- file_log("check failed: %s" % exc, str(settings.get("log_file")))
1452
- return {"ok": False, "current": current, "latest": "",
1453
- "update_available": False, "reason": "check failed: %s" % exc,
1454
- "cached": False, "checked_at": now_iso(), "release": None,
1455
- "asset": None, "channel": settings["channel"],
1456
- "auto": bool(settings["auto"]), "install_dir": install_dir}
1457
-
1458
- latest_version = str(release["version"])
1459
- available = is_newer(latest_version, current)
1460
- asset = choose_asset(release)
1461
- state.update({
1462
- "last_check": now_iso(),
1463
- "last_check_epoch": now,
1464
- "latest": _public_release(release),
1465
- "update_available": available,
1466
- "last_result": ("update available: %s" % latest_version) if available
1467
- else ("up to date (%s)" % current),
1468
- })
1469
- _write_json_atomic(str(settings["state_file"]), state)
1470
- if available:
1471
- file_log("update available: %s -> %s" % (current, latest_version),
1472
- str(settings.get("log_file")))
1473
- return {
1474
- "ok": True,
1475
- "current": current,
1476
- "latest": latest_version,
1477
- "update_available": available,
1478
- "reason": ("release %s is newer than %s" % (latest_version, current)) if available
1479
- else "up to date",
1480
- "cached": False,
1481
- "checked_at": now_iso(),
1482
- "release": _public_release(release),
1483
- "asset": None if asset is None else {"name": asset["name"], "kind": asset["kind"],
1484
- "size": asset["size"], "verified": asset["verified"]},
1485
- "channel": settings["channel"],
1486
- "auto": bool(settings["auto"]),
1487
- "install_dir": install_dir,
1488
- }
1489
-
1490
-
1491
- def apply_update(*, config: Optional[Dict[str, Any]] = None,
1492
- check: Optional[Dict[str, Any]] = None, force: bool = False,
1493
- restart: Optional[bool] = None, workdir: Optional[str] = None) -> Dict[str, Any]:
1494
- """Check, download, verify and swap in the newest release.
1495
-
1496
- ``restart`` defaults to ``MESH_UPDATE_RESTART``. Applying without a restart
1497
- is for tests and for an operator who supervises the node themselves.
1498
- """
1499
- settings = config or config_from_env()
1500
- install_dir = str(settings["install_dir"])
1501
- result = check if check is not None else check_for_update(config=settings, force=force)
1502
- answer: Dict[str, Any] = {
1503
- "ok": True,
1504
- "applied": False,
1505
- "current": result.get("current") or local_version(install_dir),
1506
- "latest": result.get("latest") or "",
1507
- "restart_scheduled": False,
1508
- "reason": "",
1509
- "release": result.get("release"),
1510
- }
1511
- if not result.get("ok"):
1512
- answer.update({"ok": False, "reason": result.get("reason") or "check failed"})
1513
- return answer
1514
- if not result.get("update_available"):
1515
- answer["reason"] = "already up to date (%s)" % answer["current"]
1516
- return answer
1517
-
1518
- state = _read_json(str(settings["state_file"])) or {}
1519
- last_failed = float(state.get("last_apply_failed_epoch") or 0.0)
1520
- if last_failed and (time.time() - last_failed) < APPLY_RETRY_BACKOFF and not force:
1521
- answer["reason"] = ("the previous attempt failed %d s ago; retrying in %d s"
1522
- % (time.time() - last_failed,
1523
- APPLY_RETRY_BACKOFF - (time.time() - last_failed)))
1524
- return answer
1525
-
1526
- try:
1527
- staged = stage_payload(result["release"], config=settings, workdir=workdir)
1528
- except UpdateError as exc:
1529
- state.update({"last_apply_failed_epoch": time.time(),
1530
- "last_result": "download failed: %s" % exc})
1531
- _write_json_atomic(str(settings["state_file"]), state)
1532
- file_log("download failed: %s" % exc, str(settings.get("log_file")))
1533
- answer.update({"ok": False, "reason": str(exc)})
1534
- return answer
1535
-
1536
- answer["asset"] = staged["asset"]
1537
- answer["sha256"] = staged["sha256"]
1538
- answer["verified"] = staged["verified"]
1539
- if settings.get("dry_run"):
1540
- answer.update({"reason": "dry run: payload %s staged and verified, nothing replaced"
1541
- % staged["asset"], "staged": staged["root"]})
1542
- return answer
1543
-
1544
- do_restart = bool(settings["restart"]) if restart is None else bool(restart)
1545
- helper: Dict[str, Any] = {}
1546
- if do_restart:
1547
- # Spawned BEFORE the swap: if this process dies during the swap, the node
1548
- # is still brought back - and recover_pending() then rolls the tree back.
1549
- helper = spawn_restart_helper(install_dir, config=settings)
1550
- answer["restart_scheduled"] = bool(helper.get("ok"))
1551
- answer["restart_target_pid"] = helper.get("target_pid")
1552
- try:
1553
- applied = apply_payload(staged["root"], install_dir, config=settings,
1554
- from_version=str(answer["current"]),
1555
- to_version=str(answer["latest"]))
1556
- except UpdateError as exc:
1557
- state = _read_json(str(settings["state_file"])) or {}
1558
- state.update({"last_apply_failed_epoch": time.time(),
1559
- "last_result": "apply failed: %s" % exc})
1560
- _write_json_atomic(str(settings["state_file"]), state)
1561
- file_log("apply failed: %s" % exc, str(settings.get("log_file")))
1562
- answer.update({"ok": False, "reason": str(exc)})
1563
- return answer
1564
-
1565
- state = _read_json(str(settings["state_file"])) or {}
1566
- state.update({"last_applied": answer["latest"],
1567
- "last_applied_epoch": time.time(),
1568
- "last_result": "applied %s -> %s" % (answer["current"], answer["latest"]),
1569
- "update_available": False,
1570
- "last_apply_failed_epoch": 0})
1571
- _write_json_atomic(str(settings["state_file"]), state)
1572
- answer.update({"applied": True, "backup": applied["backup"],
1573
- "replaced": applied["replaced"],
1574
- "reason": "applied %s -> %s" % (answer["current"], answer["latest"]),
1575
- "staged": staged["root"]})
1576
- if do_restart and answer["restart_scheduled"]:
1577
- # In-process callers (the agent's background thread, the mesh_update tool)
1578
- # stop themselves here; the helper is already waiting for exactly that.
1579
- answer["restart_requested"] = _notify_restart()
1580
- if not do_restart:
1581
- answer["reason"] += " (restart not requested)"
1582
- return answer
1583
-
1584
-
1585
- def background_tick(*, config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1586
- """One pass of the agent's background updater.
1587
-
1588
- Returns the check/apply result plus ``restart``: True when the caller must
1589
- stop the agent so the helper can start the new version.
1590
- """
1591
- settings = config or config_from_env()
1592
- result = check_for_update(config=settings)
1593
- if not result.get("ok") or not result.get("update_available"):
1594
- return {"restart": False, "check": result}
1595
- if not settings["auto"]:
1596
- return {"restart": False, "check": result,
1597
- "reason": "an update is available but MESH_UPDATE_AUTO=0"}
1598
- applied = apply_update(config=settings, check=result)
1599
- restart = bool(applied.get("applied") and settings["restart"]
1600
- and applied.get("restart_scheduled"))
1601
- return {"restart": restart, "check": result, "apply": applied}
1602
-
1603
-
1604
- # ---------------------------------------------------------------------------
1605
- # Command line
1606
- # ---------------------------------------------------------------------------
1607
-
1608
- def _print_result(result: Dict[str, Any], *, as_json: bool, stream=None) -> None:
1609
- stream = stream or sys.stdout
1610
- if as_json:
1611
- # ensure_ascii keeps the machine-readable answer 7-bit clean: release
1612
- # notes carry emoji, and a Windows console whose code page cannot encode
1613
- # them used to turn a successful check into a UnicodeEncodeError.
1614
- json.dump(result, stream, ensure_ascii=True, indent=2, sort_keys=True)
1615
- stream.write("\n")
1616
- return
1617
- if "current" in result:
1618
- stream.write("current : %s\n" % result.get("current"))
1619
- if result.get("latest"):
1620
- stream.write("latest : %s\n" % result.get("latest"))
1621
- if "update_available" in result:
1622
- stream.write("update : %s\n" % ("available" if result.get("update_available")
1623
- else "none"))
1624
- for key in ("reason", "action", "asset", "sha256", "verified", "backup",
1625
- "restart_scheduled", "install_dir", "log_file"):
1626
- if result.get(key) not in (None, "", {}):
1627
- stream.write("%-13s: %s\n" % (key, result.get(key)))
1628
- release = result.get("release") or {}
1629
- if release.get("html_url"):
1630
- stream.write("release : %s\n" % release["html_url"])
1631
-
1632
-
1633
- def _make_output_safe() -> None:
1634
- """Never let an unprintable character turn a result into a crash.
1635
-
1636
- Release notes are written by humans and carry emoji and non-Latin text; a
1637
- Windows console is often a cp1251/cp866 code page. Replacing what the console
1638
- cannot encode is right for a diagnostic tool - the exit code and the JSON
1639
- answer are what scripts read.
1640
- """
1641
- for stream in (sys.stdout, sys.stderr):
1642
- try:
1643
- stream.reconfigure(errors="replace")
1644
- except Exception:
1645
- pass
1646
-
1647
-
1648
- def main(argv: Optional[Sequence[str]] = None) -> int:
1649
- parser = argparse.ArgumentParser(
1650
- prog="python -m core.updater",
1651
- description="Check for, download and apply Antigravity Mesh updates.")
1652
- actions = parser.add_mutually_exclusive_group()
1653
- actions.add_argument("--status", action="store_true",
1654
- help="report local state; no network, no changes")
1655
- actions.add_argument("--check", action="store_true",
1656
- help="ask GitHub whether a newer release exists")
1657
- actions.add_argument("--apply", action="store_true",
1658
- help="check, download, verify, swap and restart")
1659
- parser.add_argument("--restart-helper", action="store_true",
1660
- help=argparse.SUPPRESS)
1661
- parser.add_argument("--pid", type=int, default=0, help=argparse.SUPPRESS)
1662
- parser.add_argument("--dir", default="", help="install directory override")
1663
- parser.add_argument("--wait", type=float, default=120.0, help=argparse.SUPPRESS)
1664
- parser.add_argument("--force", action="store_true",
1665
- help="ignore the check interval and the retry backoff")
1666
- parser.add_argument("--offline", action="store_true",
1667
- help="report the cached state without touching the network")
1668
- parser.add_argument("--no-restart", action="store_true",
1669
- help="apply the payload but do not restart the agent")
1670
- parser.add_argument("--dry-run", action="store_true",
1671
- help="download and verify, change nothing")
1672
- parser.add_argument("--json", action="store_true", help="machine-readable output")
1673
- parser.add_argument("--quiet", action="store_true", help="print nothing on success")
1674
- args = parser.parse_args(list(argv) if argv is not None else None)
1675
- _make_output_safe()
1676
-
1677
- if args.restart_helper:
1678
- return restart_helper_main(args.pid, args.dir or default_install_dir(), args.wait)
1679
-
1680
- config = config_from_env(install_dir=args.dir or None)
1681
- if args.dry_run:
1682
- config["dry_run"] = True
1683
-
1684
- if args.status:
1685
- result = status(config=config)
1686
- if not args.quiet:
1687
- _print_result(result, as_json=args.json)
1688
- return 0
1689
-
1690
- if args.apply:
1691
- result = apply_update(config=config, force=args.force,
1692
- restart=False if args.no_restart else None)
1693
- if not args.quiet:
1694
- _print_result(result, as_json=args.json)
1695
- if not result.get("ok"):
1696
- return 3
1697
- if result.get("applied") and result.get("restart_scheduled"):
1698
- return 4
1699
- return 0
1700
-
1701
- result = check_for_update(config=config, force=args.force, offline=args.offline)
1702
- if not args.quiet:
1703
- _print_result(result, as_json=args.json)
1704
- if not result.get("ok"):
1705
- return 3
1706
- return 2 if result.get("update_available") else 0
1707
-
1708
-
1709
- if __name__ == "__main__":
1710
- try:
1711
- sys.exit(main())
1712
- except KeyboardInterrupt:
1713
- sys.exit(130)