gemini-computer-use 0.2.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +935 -148
  2. package/bin/cli.js +112 -17
  3. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  4. package/core/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/core/__pycache__/code_cache.cpython-313.pyc +0 -0
  6. package/core/__pycache__/mcp_tools.cpython-313.pyc +0 -0
  7. package/core/__pycache__/schemas.cpython-313.pyc +0 -0
  8. package/core/__pycache__/server.cpython-313.pyc +0 -0
  9. package/core/__pycache__/vitals.cpython-313.pyc +0 -0
  10. package/core/__pycache__/web_share.cpython-313.pyc +0 -0
  11. package/core/agent.py +688 -27
  12. package/core/agent_harness.py +9 -2
  13. package/core/code_cache.py +15 -3
  14. package/core/device.py +2400 -0
  15. package/core/domain.py +303 -0
  16. package/core/mcp_tools.py +1949 -110
  17. package/core/policies.py +35 -5
  18. package/core/server.py +78 -32
  19. package/core/termux.py +916 -0
  20. package/core/updater.py +1713 -0
  21. package/core/version.py +31 -0
  22. package/core/vitals.py +66 -2
  23. package/core/web_share.py +814 -0
  24. package/gateway.py +1469 -98
  25. package/install-gui.cmd +40 -0
  26. package/install-gui.ps1 +1882 -0
  27. package/install-gui.strings.json +211 -0
  28. package/install.ps1 +960 -92
  29. package/install.sh +1214 -81
  30. package/ops/doctor.ps1 +735 -0
  31. package/ops/mesh-domain.sh +86 -0
  32. package/ops/nginx/README.md +77 -0
  33. package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
  34. package/ops/nginx/mesh-domain.vhost.template +58 -0
  35. package/ops/nginx/render-domain.sh +123 -0
  36. package/ops/update.ps1 +373 -0
  37. package/ops/windows/README.md +298 -0
  38. package/ops/windows/agent-watchdog.ps1 +575 -0
  39. package/ops/windows/run-hidden.vbs +64 -0
  40. package/package.json +6 -2
  41. package/skills/orchestrator.md +12 -0
@@ -0,0 +1,814 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """core/web_share.py - publish local files and directories through the Mesh gateway.
4
+
5
+ Why this module exists
6
+ ----------------------
7
+ A node sits behind NAT and never opens a public port: the gateway on the shared
8
+ domain reaches it only through the outbound websocket tunnel. File sharing
9
+ therefore works in two halves:
10
+
11
+ * the node runs a real, loopback-only static HTTP server rooted at the shared
12
+ directory (``serve_dir``) or at a per-file copy (``share_file``);
13
+ * the gateway relays ``GET/HEAD https://<domain>/<node>/<slug>/...`` over the
14
+ existing tunnel into that local server (``handle_http_request``) and returns
15
+ the response to the browser.
16
+
17
+ Public URL contract::
18
+
19
+ https://<shared-domain>/<node-name>/<dir-name>-<16 hex>/[path]
20
+
21
+ ``<shared-domain>`` is never decided here: the public host is owned by
22
+ :mod:`core.domain`. An explicit ``configure(public_url=...)`` still wins, because
23
+ a caller that knows the domain better than the environment does must be able to
24
+ say so; everything else asks :func:`core.domain.public_base_url`.
25
+
26
+ The trailing random suffix is the share secret. Node names are registered and
27
+ therefore guessable; the 64-bit suffix is not, which is what makes the link
28
+ itself the credential (MESH_WEB_SECRET_BITS). A wrong or missing suffix is
29
+ answered with a plain 404, exactly like a share that does not exist.
30
+
31
+ Security properties
32
+ -------------------
33
+ * the local server binds ``127.0.0.1`` on an ephemeral port only;
34
+ * the public surface is read-only: only ``GET``/``HEAD`` are proxied;
35
+ * path traversal is rejected by ``SimpleHTTPRequestHandler.translate_path``,
36
+ which drops ``..`` components, and re-checked here before serving; symlinks
37
+ inside a served directory are followed, exactly like any static web server;
38
+ * a response larger than ``MESH_WEB_MAX_BYTES`` is answered with 413 instead of
39
+ being pushed through the tunnel;
40
+ * ``share_file`` copies the file into the share root, so the published bytes
41
+ never change when the source file does; ``serve_dir`` serves in place and
42
+ never deletes the user's directory on ``unshare``.
43
+
44
+ Standard library only (Python 3.8+), like the rest of ``core/``.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import base64
50
+ import json
51
+ import logging
52
+ import os
53
+ import re
54
+ import secrets
55
+ import shutil
56
+ import threading
57
+ from datetime import datetime, timezone
58
+ from functools import partial
59
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
60
+ from typing import Any, Dict, List, Optional
61
+ from urllib.error import HTTPError
62
+ from urllib.parse import quote, urlsplit
63
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
64
+
65
+ #: The public domain is owned by core/domain.py, exactly as it is for the gateway,
66
+ #: the tunnel node and core/mcp_tools.py: this module must not keep a fallback
67
+ #: chain of its own, or the link a share hands out could name a host the node is
68
+ #: not tunneled to. core.domain imports nothing but the standard library, so
69
+ #: importing it here keeps this module stdlib-only too.
70
+ from core import domain
71
+
72
+ logger = logging.getLogger("antigravity-mesh.web_share")
73
+
74
+ #: Bytes of entropy in a share secret -> 16 hex characters in the URL.
75
+ SECRET_BYTES = 8
76
+ #: Maximum length of the human-readable part of a share name/slug.
77
+ NAME_MAX = 40
78
+ DEFAULT_MAX_SHARES = 32
79
+ MIN_MAX_BYTES = 1024
80
+ #: Default public payload limit. The body travels through the tunnel as one
81
+ #: base64 JSON message, which grows it by ~4/3 plus framing, so the default must
82
+ #: stay well below the gateway's websocket message cap (uvicorn's ws_max_size,
83
+ #: raised to 128 MiB in gateway.py). 32 MiB -> ~42.7 MiB on the wire.
84
+ DEFAULT_MAX_BYTES = 32 * 1024 * 1024
85
+ #: Hard ceiling: 64 MiB -> ~85.3 MiB on the wire, safely under the 128 MiB cap.
86
+ #: Operators raise the per-node limit with MESH_WEB_MAX_BYTES up to this value.
87
+ MAX_MAX_BYTES = 64 * 1024 * 1024
88
+
89
+ #: The public slug is ``<name>-<hex secret>``. The gateway and the nginx snippet
90
+ #: recognise the same shape, so it is part of the wire contract.
91
+ SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}-[0-9a-f]{%d}$" % (SECRET_BYTES * 2))
92
+ _NAME_CLEAN_RE = re.compile(r"[^a-z0-9_-]+")
93
+ _DASH_RUN_RE = re.compile(r"-{2,}")
94
+
95
+ #: Response headers a share may expose publicly. Everything else the local
96
+ #: server emits (Server, Date, ...) is dropped instead of being relayed.
97
+ _KEEP_HEADERS = (
98
+ "content-type",
99
+ "content-disposition",
100
+ "last-modified",
101
+ "etag",
102
+ "cache-control",
103
+ "location",
104
+ "accept-ranges",
105
+ )
106
+
107
+ _CONFIG: Dict[str, Any] = {}
108
+ _LOCK = threading.RLock()
109
+ _SERVERS: Dict[str, Dict[str, Any]] = {}
110
+ #: Per-thread re-entrancy depth for :class:`_RegistryGuard`.
111
+ _GUARD_LOCAL = threading.local()
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Configuration
116
+ # ---------------------------------------------------------------------------
117
+
118
+ def configure(**kwargs: Any) -> None:
119
+ """(Re)configure the module.
120
+
121
+ Recognised keys: ``web_dir``, ``public_url``, ``user``, ``max_bytes``,
122
+ ``max_shares``, ``listing``. Unset keys keep their previous value; an empty
123
+ string resets a key so it falls back to the ``MESH_*`` environment and then
124
+ to the built-in default, which keeps tests order-independent.
125
+ """
126
+ for key in ("web_dir", "public_url", "user", "max_bytes", "max_shares", "listing"):
127
+ if key not in kwargs or kwargs[key] is None:
128
+ continue
129
+ if kwargs[key] == "":
130
+ _CONFIG.pop(key, None)
131
+ else:
132
+ _CONFIG[key] = kwargs[key]
133
+
134
+
135
+ def _env_int(name: str, default: int, low: int = 1, high: int = 1 << 40) -> int:
136
+ raw = os.environ.get(name)
137
+ if raw is None or str(raw).strip() == "":
138
+ return default
139
+ try:
140
+ value = int(str(raw).strip())
141
+ except (TypeError, ValueError):
142
+ return default
143
+ return max(low, min(high, value))
144
+
145
+
146
+ def _web_dir() -> str:
147
+ raw = _CONFIG.get("web_dir") or os.environ.get("MESH_WEB_DIR") or os.path.join(
148
+ "~", ".cache", "antigravity-mesh", "web"
149
+ )
150
+ return os.path.realpath(os.path.expanduser(str(raw)))
151
+
152
+
153
+ def _public_root() -> str:
154
+ return os.path.join(_web_dir(), "public")
155
+
156
+
157
+ def _registry_path() -> str:
158
+ return os.path.join(_web_dir(), "shares.json")
159
+
160
+
161
+ def _max_bytes() -> int:
162
+ if _CONFIG.get("max_bytes") is not None:
163
+ try:
164
+ return max(MIN_MAX_BYTES, min(MAX_MAX_BYTES, int(_CONFIG["max_bytes"])))
165
+ except (TypeError, ValueError):
166
+ pass
167
+ return _env_int("MESH_WEB_MAX_BYTES", DEFAULT_MAX_BYTES, MIN_MAX_BYTES, MAX_MAX_BYTES)
168
+
169
+
170
+ def _max_shares() -> int:
171
+ if _CONFIG.get("max_shares") is not None:
172
+ try:
173
+ return max(1, int(_CONFIG["max_shares"]))
174
+ except (TypeError, ValueError):
175
+ pass
176
+ return _env_int("MESH_WEB_MAX_SHARES", DEFAULT_MAX_SHARES, 1)
177
+
178
+
179
+ def _listing_enabled() -> bool:
180
+ if _CONFIG.get("listing") is not None:
181
+ return str(_CONFIG["listing"]).strip().lower() in ("1", "true", "yes", "on")
182
+ return str(os.environ.get("MESH_WEB_LISTING", "1")).strip().lower() in ("1", "true", "yes", "on")
183
+
184
+
185
+ def _public_base() -> str:
186
+ """Public base URL for share links.
187
+
188
+ An explicit ``configure(public_url=...)`` is the highest priority - a caller
189
+ that injected a domain knows it better than the environment does. Everything
190
+ else is answered by :mod:`core.domain`, which owns the whole chain
191
+ (``MESH_PUBLIC_URL``, the domain file, the legacy ``MESH_GATEWAY``, then the
192
+ one project default); duplicating any of it here would let the link and the
193
+ tunnel disagree about the host.
194
+ """
195
+ explicit = _CONFIG.get("public_url")
196
+ if explicit:
197
+ return domain.normalise_public_base_url(explicit)
198
+ return domain.public_base_url()
199
+
200
+
201
+ def _user() -> str:
202
+ raw = _CONFIG.get("user") or os.environ.get("MESH_USER") or "anonymous"
203
+ user = str(raw).strip().lower()
204
+ return user or "anonymous"
205
+
206
+
207
+ def _public_url(slug: str, suffix: str = "/") -> str:
208
+ suffix = suffix or "/"
209
+ if not suffix.startswith("/"):
210
+ suffix = "/" + suffix
211
+ return "%s/%s/%s%s" % (_public_base(), _user(), slug, suffix)
212
+
213
+
214
+ def _now_iso() -> str:
215
+ return datetime.now(timezone.utc).isoformat()
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Registry (persisted so a URL survives an agent restart)
220
+ # ---------------------------------------------------------------------------
221
+
222
+ def _load_registry() -> Dict[str, Dict[str, Any]]:
223
+ try:
224
+ with open(_registry_path(), "r", encoding="utf-8") as handle:
225
+ data = json.load(handle)
226
+ if isinstance(data, dict):
227
+ return {str(k): v for k, v in data.items() if isinstance(v, dict)}
228
+ except Exception:
229
+ pass
230
+ return {}
231
+
232
+
233
+ def _save_registry(registry: Dict[str, Dict[str, Any]]) -> None:
234
+ path = _registry_path()
235
+ os.makedirs(os.path.dirname(path), exist_ok=True)
236
+ # A per-process temp name: the standalone server and the tunnel agent can
237
+ # share one MESH_WEB_DIR, and a common "shares.json.tmp" would make one
238
+ # process rename the other's half-written file.
239
+ tmp = "%s.%d.tmp" % (path, os.getpid())
240
+ with open(tmp, "w", encoding="utf-8") as handle:
241
+ json.dump(registry, handle, ensure_ascii=False, indent=2)
242
+ os.replace(tmp, path)
243
+ try:
244
+ os.chmod(path, 0o600)
245
+ except OSError: # pragma: no cover - platform dependent
246
+ pass
247
+
248
+
249
+ class _RegistryGuard(object):
250
+ """Serialise a registry read-modify-write across processes and threads.
251
+
252
+ Two node processes may point at one ``MESH_WEB_DIR`` (the tunnel agent and
253
+ the standalone MCP server), so the in-process ``_LOCK`` alone would let one
254
+ process overwrite a share the other just saved. The OS releases this lock
255
+ when the process dies, so a crash cannot leave it behind.
256
+
257
+ The guard is re-entrant per thread: a second ``flock`` on a *new* file
258
+ descriptor would block even though this process already holds the lock, so a
259
+ nested ``with _RegistryGuard():`` must not try to lock twice.
260
+ """
261
+
262
+ def __init__(self) -> None:
263
+ self._fh = None
264
+ self._outermost = False
265
+
266
+ def __enter__(self) -> "_RegistryGuard":
267
+ _LOCK.acquire()
268
+ depth = getattr(_GUARD_LOCAL, "depth", 0)
269
+ _GUARD_LOCAL.depth = depth + 1
270
+ if depth:
271
+ return self # this thread already holds the file lock
272
+ self._outermost = True
273
+ try:
274
+ directory = _web_dir()
275
+ os.makedirs(directory, exist_ok=True)
276
+ self._fh = open(os.path.join(directory, "registry.lock"), "a+")
277
+ if os.name == "nt":
278
+ import msvcrt
279
+ self._fh.seek(0)
280
+ msvcrt.locking(self._fh.fileno(), msvcrt.LK_LOCK, 1)
281
+ else:
282
+ import fcntl
283
+ fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
284
+ except Exception:
285
+ # Locking is best effort: never fail a tool because the FS refuses a lock.
286
+ if self._fh is not None:
287
+ try:
288
+ self._fh.close()
289
+ except Exception:
290
+ pass
291
+ self._fh = None
292
+ return self
293
+
294
+ def __exit__(self, *_exc: Any) -> None:
295
+ depth = getattr(_GUARD_LOCAL, "depth", 1)
296
+ _GUARD_LOCAL.depth = max(0, depth - 1)
297
+ if self._outermost:
298
+ self._outermost = False
299
+ if self._fh is not None:
300
+ try:
301
+ if os.name != "nt":
302
+ import fcntl
303
+ fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
304
+ except Exception:
305
+ pass
306
+ try:
307
+ self._fh.close()
308
+ except Exception:
309
+ pass
310
+ self._fh = None
311
+ _LOCK.release()
312
+
313
+
314
+ def _clean_name(value: Any, fallback: str = "share") -> str:
315
+ text = _NAME_CLEAN_RE.sub("-", str(value or "").strip().lower())
316
+ text = _DASH_RUN_RE.sub("-", text).strip("-_")
317
+ return (text or fallback)[:NAME_MAX]
318
+
319
+
320
+ def _make_slug(name: str) -> str:
321
+ return "%s-%s" % (_clean_name(name), secrets.token_hex(SECRET_BYTES))
322
+
323
+
324
+ def _find_by_name(registry: Dict[str, Dict[str, Any]], name: str) -> Optional[str]:
325
+ for slug, share in registry.items():
326
+ if share.get("name") == name:
327
+ return slug
328
+ return None
329
+
330
+
331
+ def _inside(path: str, base: str) -> bool:
332
+ """True when ``path`` is ``base`` or lives below it (both real paths)."""
333
+ try:
334
+ path = os.path.realpath(path)
335
+ base = os.path.realpath(base)
336
+ return os.path.commonpath([path, base]) == base
337
+ except Exception:
338
+ return False
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Local static server
343
+ # ---------------------------------------------------------------------------
344
+
345
+ class _ShareHandler(SimpleHTTPRequestHandler):
346
+ """Loopback-only static handler that rewrites redirects to the public URL.
347
+
348
+ ``SimpleHTTPRequestHandler`` answers a directory without a trailing slash
349
+ with ``301 Location: /subdir/``. That path is relative to the *share root*,
350
+ so it must be prefixed with the public ``/<node>/<slug>`` prefix before it
351
+ reaches the browser - otherwise the redirect leaves the share namespace.
352
+
353
+ ``translate_path`` is wrapped so a symlink inside the shared directory can
354
+ never publish a file from outside it: the resolved path must stay under the
355
+ share root, otherwise the request is answered with a plain 404.
356
+ """
357
+
358
+ server_version = "AntigravityMeshShare/1.0"
359
+ protocol_version = "HTTP/1.1"
360
+
361
+ def __init__(self, *args: Any, directory: Optional[str] = None,
362
+ public_prefix: str = "", listing: bool = True, **kwargs: Any) -> None:
363
+ self.public_prefix = public_prefix or ""
364
+ self.listing_enabled = bool(listing)
365
+ self._redirecting = False
366
+ self._root = os.path.realpath(directory) if directory else ""
367
+ super().__init__(*args, directory=directory, **kwargs)
368
+
369
+ def translate_path(self, path: str) -> str: # noqa: N802
370
+ translated = super().translate_path(path)
371
+ if not _inside(translated, self._root):
372
+ # A symlink (or any other resolution) that leaves the share root is
373
+ # not published; this path does not exist, so send_head answers 404.
374
+ logger.warning("share %s: refused a path outside the root: %r", self.public_prefix, path)
375
+ return os.path.join(self._root, ".mesh-share-denied")
376
+ return translated
377
+
378
+ def send_response(self, code: int, message: Optional[str] = None) -> None: # noqa: N802
379
+ try:
380
+ self._redirecting = int(code) in (301, 302, 303, 307, 308)
381
+ except (TypeError, ValueError):
382
+ self._redirecting = False
383
+ super().send_response(code, message)
384
+
385
+ def send_header(self, keyword: str, value: Any) -> None: # noqa: N802
386
+ if (
387
+ self._redirecting
388
+ and str(keyword).lower() == "location"
389
+ and isinstance(value, str)
390
+ and value.startswith("/")
391
+ ):
392
+ value = self.public_prefix + value
393
+ super().send_header(keyword, value)
394
+
395
+ def list_directory(self, path: str): # type: ignore[override]
396
+ if not self.listing_enabled:
397
+ self.send_error(403, "Directory listing is disabled")
398
+ return None
399
+ return super().list_directory(path)
400
+
401
+ def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
402
+ logger.debug("%s - %s", self.address_string(), fmt % args)
403
+
404
+
405
+ def _start_server(slug: str, share: Dict[str, Any]) -> Dict[str, Any]:
406
+ handler = partial(
407
+ _ShareHandler,
408
+ directory=share.get("root") or "",
409
+ public_prefix="/%s/%s" % (_user(), slug),
410
+ listing=_listing_enabled(),
411
+ )
412
+ # Port 0 -> the OS assigns a free loopback port; nothing is ever bound publicly.
413
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
414
+ httpd.daemon_threads = True
415
+ thread = threading.Thread(target=httpd.serve_forever, name="mesh-share-%s" % slug[:24], daemon=True)
416
+ thread.start()
417
+ entry = {"httpd": httpd, "thread": thread, "port": int(httpd.server_address[1])}
418
+ _SERVERS[slug] = entry
419
+ logger.info("share %s listening on 127.0.0.1:%s", slug, entry["port"])
420
+ return entry
421
+
422
+
423
+ def _ensure_server(slug: str) -> Optional[Dict[str, Any]]:
424
+ """Return the running server for ``slug``, starting it lazily if needed.
425
+
426
+ Laziness is what makes a URL survive an agent restart: the registry is on
427
+ disk, and the first request after the restart brings the server back up.
428
+ """
429
+ with _LOCK:
430
+ share = _load_registry().get(slug)
431
+ if not share:
432
+ return None
433
+ entry = _SERVERS.get(slug)
434
+ if entry and entry["thread"].is_alive():
435
+ return entry
436
+ root = share.get("root") or ""
437
+ if not root or not os.path.isdir(root):
438
+ return None
439
+ return _start_server(slug, share)
440
+
441
+
442
+ def _stop_server(slug: str) -> bool:
443
+ entry = _SERVERS.pop(slug, None)
444
+ if not entry:
445
+ return False
446
+ try:
447
+ entry["httpd"].shutdown()
448
+ except Exception:
449
+ pass
450
+ try:
451
+ entry["httpd"].server_close()
452
+ except Exception:
453
+ pass
454
+ return True
455
+
456
+
457
+ def stop_all() -> int:
458
+ """Stop every local share server (shutdown / test cleanup). Returns the count."""
459
+ with _LOCK:
460
+ slugs = list(_SERVERS.keys())
461
+ for slug in slugs:
462
+ _stop_server(slug)
463
+ return len(slugs)
464
+
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # Public HTTP proxy (gateway -> node)
468
+ # ---------------------------------------------------------------------------
469
+
470
+ class _NoRedirect(HTTPRedirectHandler):
471
+ """Do not follow redirects: the browser must see the (rewritten) 301."""
472
+
473
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
474
+ return None
475
+
476
+
477
+ _OPENER = build_opener(_NoRedirect)
478
+
479
+
480
+ def _plain(status: int, text: str, content_type: str = "text/plain; charset=utf-8",
481
+ extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
482
+ body = text.encode("utf-8", "replace")
483
+ headers = {"content-type": content_type, "content-length": str(len(body))}
484
+ if extra_headers:
485
+ headers.update(extra_headers)
486
+ return {"status": status, "headers": headers, "body_b64": base64.b64encode(body).decode("ascii")}
487
+
488
+
489
+ def handle_http_request(args: Dict[str, Any]) -> Dict[str, Any]:
490
+ """Serve one public ``GET``/``HEAD`` from the gateway through the tunnel.
491
+
492
+ Returns ``{"status", "headers", "body_b64"}`` for the gateway to relay. A
493
+ missing share and a wrong secret are both an opaque 404, so the endpoint
494
+ cannot be used to enumerate node names or share names.
495
+ """
496
+ if not isinstance(args, dict):
497
+ return _plain(400, "bad request\n")
498
+ method = str(args.get("method") or "GET").upper()
499
+ if method not in ("GET", "HEAD"):
500
+ return _plain(405, "method not allowed\n", extra_headers={"allow": "GET, HEAD"})
501
+ slug = str(args.get("slug") or "")
502
+ if not SLUG_RE.match(slug):
503
+ return _plain(404, "not found\n")
504
+ entry = _ensure_server(slug)
505
+ if entry is None:
506
+ return _plain(404, "not found\n")
507
+
508
+ path = str(args.get("path") or "/")
509
+ if "\x00" in path:
510
+ # A NUL would make the local open() raise; answer the same opaque 404.
511
+ return _plain(404, "not found\n")
512
+ if not path.startswith("/"):
513
+ path = "/" + path
514
+ query = str(args.get("query") or "").lstrip("?")
515
+ # ``safe="/%"`` keeps an existing percent-encoding intact and never lets a
516
+ # literal "?" or "#" escape into the request line.
517
+ url = "http://127.0.0.1:%d%s" % (entry["port"], quote(path, safe="/%"))
518
+ if query:
519
+ url += "?" + query
520
+
521
+ request = Request(url, method=method, headers={
522
+ "User-Agent": "antigravity-mesh-gateway/1.0",
523
+ "Accept": "*/*",
524
+ })
525
+ response = None
526
+ try:
527
+ try:
528
+ response = _OPENER.open(request, timeout=20)
529
+ except HTTPError as exc:
530
+ response = exc
531
+ status = int(getattr(response, "status", None) or response.getcode())
532
+ raw_headers = {str(k).lower(): str(v) for k, v in response.headers.items()}
533
+ limit = _max_bytes()
534
+ body = b"" if method == "HEAD" else response.read(limit + 1)
535
+ except Exception as exc:
536
+ logger.warning("share %s: local server error: %s", slug, exc)
537
+ return _plain(502, "share server error\n")
538
+ finally:
539
+ if response is not None:
540
+ try:
541
+ response.close()
542
+ except Exception:
543
+ pass
544
+
545
+ if len(body) > limit:
546
+ logger.warning("share %s: response exceeds %d bytes", slug, limit)
547
+ return _plain(413, "file exceeds the share response limit of %d bytes\n" % limit)
548
+
549
+ headers = {k: v for k, v in raw_headers.items() if k in _KEEP_HEADERS}
550
+ headers.setdefault("content-type", "application/octet-stream")
551
+ if method == "HEAD":
552
+ # A HEAD body is empty, so judge the size from the declared length: an
553
+ # over-limit resource must answer 413 for HEAD exactly as it does for GET.
554
+ try:
555
+ declared = int(raw_headers.get("content-length") or 0)
556
+ except (TypeError, ValueError):
557
+ declared = 0
558
+ if declared > limit:
559
+ logger.warning("share %s: resource exceeds %d bytes", slug, limit)
560
+ return _plain(413, "file exceeds the share response limit of %d bytes\n" % limit)
561
+ headers["content-length"] = raw_headers.get("content-length", "0")
562
+ else:
563
+ headers["content-length"] = str(len(body))
564
+ return {"status": status, "headers": headers, "body_b64": base64.b64encode(body).decode("ascii")}
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # MCP tools
569
+ # ---------------------------------------------------------------------------
570
+
571
+ def share_file(path: Any, name: Any = None, overwrite: Any = False) -> Dict[str, Any]:
572
+ """Publish one file: copy it into the share root and return its public URL."""
573
+ if not path:
574
+ return {"error": "'path' is required"}
575
+ source = os.path.realpath(os.path.expanduser(str(path)))
576
+ if not os.path.isfile(source):
577
+ if os.path.isdir(source):
578
+ return {"error": "not a file: %s (use serve_dir to publish a directory)" % path}
579
+ return {"error": "file not found: %s" % path}
580
+ size = os.path.getsize(source)
581
+ limit = _max_bytes()
582
+ if size > limit:
583
+ return {
584
+ "error": "file is %d bytes; the public share limit is %d bytes "
585
+ "(raise MESH_WEB_MAX_BYTES if this is intentional)" % (size, limit)
586
+ }
587
+
588
+ display = _clean_name(name or os.path.splitext(os.path.basename(source))[0], "file")
589
+ with _RegistryGuard():
590
+ registry = _load_registry()
591
+ existing = _find_by_name(registry, display)
592
+ if existing and not _truthy(overwrite):
593
+ return {
594
+ "error": "share name '%s' already exists (slug %s); pass overwrite=true to replace it "
595
+ "or choose another name" % (display, existing)
596
+ }
597
+ if existing:
598
+ _drop_locked(registry, existing)
599
+ if len(registry) >= _max_shares():
600
+ return {
601
+ "error": "share limit reached (%d); call unshare for a share you no longer need"
602
+ % _max_shares()
603
+ }
604
+ slug = _make_slug(display)
605
+ root = os.path.join(_public_root(), slug)
606
+ os.makedirs(root, exist_ok=True)
607
+ filename = os.path.basename(source) or "file"
608
+ destination = os.path.join(root, filename)
609
+ try:
610
+ shutil.copy2(source, destination)
611
+ except Exception as exc:
612
+ shutil.rmtree(root, ignore_errors=True)
613
+ return {"error": "could not copy the file into the share root: %s" % exc}
614
+ share = {
615
+ "kind": "file",
616
+ "name": display,
617
+ "slug": slug,
618
+ "root": root,
619
+ "filename": filename,
620
+ "source": source,
621
+ "size": size,
622
+ "created_at": _now_iso(),
623
+ }
624
+ registry[slug] = share
625
+ _save_registry(registry)
626
+ entry = _start_server(slug, share)
627
+
628
+ url = _public_url(slug, "/" + quote(filename))
629
+ return {
630
+ "ok": True,
631
+ "url": url,
632
+ "slug": slug,
633
+ "name": display,
634
+ "kind": "file",
635
+ "file": destination,
636
+ "size": size,
637
+ "local_url": "http://127.0.0.1:%d/%s" % (entry["port"], quote(filename)),
638
+ "note": "The URL is the credential: anyone who has it can read this file. "
639
+ "Call unshare to revoke it.",
640
+ }
641
+
642
+
643
+ def serve_dir(path: Any, name: Any = None) -> Dict[str, Any]:
644
+ """Start a loopback static web server for ``path`` and return its public URL."""
645
+ if not path:
646
+ return {"error": "'path' is required"}
647
+ root = os.path.realpath(os.path.expanduser(str(path)))
648
+ if not os.path.isdir(root):
649
+ return {"error": "not a directory: %s" % path}
650
+ # Refuse the share storage itself (and any ancestor of it): publishing the
651
+ # web dir would expose shares.json - every slug and every source path.
652
+ for storage in (_web_dir(), _public_root()):
653
+ if _inside(root, storage) or _inside(storage, root):
654
+ return {
655
+ "error": "refusing to publish '%s': it overlaps the share storage directory (%s); "
656
+ "the registry contains share secrets" % (root, storage)
657
+ }
658
+
659
+ display = _clean_name(name or os.path.basename(root), "dir")
660
+ with _RegistryGuard():
661
+ registry = _load_registry()
662
+ existing = _find_by_name(registry, display)
663
+ if existing:
664
+ return {
665
+ "error": "share name '%s' already exists (slug %s); call unshare first or pass "
666
+ "another name" % (display, existing)
667
+ }
668
+ if len(registry) >= _max_shares():
669
+ return {
670
+ "error": "share limit reached (%d); call unshare for a share you no longer need"
671
+ % _max_shares()
672
+ }
673
+ slug = _make_slug(display)
674
+ share = {
675
+ "kind": "dir",
676
+ "name": display,
677
+ "slug": slug,
678
+ "root": root,
679
+ "source": root,
680
+ "created_at": _now_iso(),
681
+ }
682
+ registry[slug] = share
683
+ _save_registry(registry)
684
+ entry = _start_server(slug, share)
685
+
686
+ return {
687
+ "ok": True,
688
+ "url": _public_url(slug, "/"),
689
+ "slug": slug,
690
+ "name": display,
691
+ "kind": "dir",
692
+ "root": root,
693
+ "local_url": "http://127.0.0.1:%d/" % entry["port"],
694
+ "note": "Served in place from the node (no copy). The URL is the credential; "
695
+ "call unshare to stop the server.",
696
+ }
697
+
698
+
699
+ def share_list() -> Dict[str, Any]:
700
+ """List active shares with their public URLs."""
701
+ with _RegistryGuard():
702
+ registry = _load_registry()
703
+ shares: List[Dict[str, Any]] = []
704
+ for slug, share in sorted(registry.items(), key=lambda item: str(item[1].get("created_at") or "")):
705
+ entry = _SERVERS.get(slug)
706
+ running = bool(entry and entry["thread"].is_alive())
707
+ suffix = "/"
708
+ if share.get("kind") == "file" and share.get("filename"):
709
+ suffix = "/" + quote(str(share["filename"]))
710
+ shares.append({
711
+ "name": share.get("name"),
712
+ "slug": slug,
713
+ "kind": share.get("kind"),
714
+ "url": _public_url(slug, suffix),
715
+ "local_url": ("http://127.0.0.1:%d/" % entry["port"]) if entry else None,
716
+ "root": share.get("root"),
717
+ "source": share.get("source"),
718
+ "size_bytes": share.get("size"),
719
+ "created_at": share.get("created_at"),
720
+ "running": running,
721
+ })
722
+ return {
723
+ "shares": shares,
724
+ "count": len(shares),
725
+ "public_base": _public_base(),
726
+ "user": _user(),
727
+ }
728
+
729
+
730
+ def unshare(target: Any) -> Dict[str, Any]:
731
+ """Stop a share and revoke its URL. Accepts the share name, slug or public URL."""
732
+ if not target:
733
+ return {"error": "'name' is required"}
734
+ with _RegistryGuard():
735
+ registry = _load_registry()
736
+ slug = _resolve_slug(registry, str(target))
737
+ if isinstance(slug, dict):
738
+ return slug
739
+ share = registry.pop(slug)
740
+ _save_registry(registry)
741
+ stopped = _stop_server(slug)
742
+ removed = False
743
+ root = str(share.get("root") or "")
744
+ # Only ever delete the directory this module created for a file share -
745
+ # never the shared storage root itself, never a user directory.
746
+ if share.get("kind") == "file" and _removable_copy(root):
747
+ shutil.rmtree(root, ignore_errors=True)
748
+ removed = True
749
+ return {
750
+ "ok": True,
751
+ "slug": slug,
752
+ "name": share.get("name"),
753
+ "server_stopped": stopped,
754
+ "files_removed": removed,
755
+ }
756
+
757
+
758
+ def _resolve_slug(registry: Dict[str, Dict[str, Any]], target: str):
759
+ """Resolve a name/slug/URL to a slug, or return an error dict.
760
+
761
+ A public URL is ``/<node>/<slug>/<path>``, so the slug is the first segment
762
+ after the node name. Segments are never scanned back-to-front: a file inside
763
+ one share may be named exactly like another share's slug, and matching that
764
+ would revoke the wrong share.
765
+ """
766
+ key = target.strip()
767
+ if key in registry:
768
+ return key
769
+ path = key
770
+ if "://" in key:
771
+ try:
772
+ path = urlsplit(key).path
773
+ except Exception:
774
+ path = ""
775
+ segments = [segment for segment in path.strip("/").split("/") if segment]
776
+ if segments:
777
+ if segments[0] == _user().lower() and len(segments) > 1:
778
+ head = segments[1]
779
+ else:
780
+ head = segments[0]
781
+ if head in registry:
782
+ return head
783
+ # Accept the human name as it was typed: share names are stored lowercased.
784
+ folded = (segments[0] if len(segments) == 1 else key).lower()
785
+ matches = [slug for slug, share in registry.items() if str(share.get("name") or "") == folded]
786
+ if len(matches) == 1:
787
+ return matches[0]
788
+ if len(matches) > 1:
789
+ return {"error": "ambiguous share name '%s'; use one of the slugs: %s" % (key, ", ".join(matches))}
790
+ return {"error": "no share named '%s' (call share_list to see active shares)" % target}
791
+
792
+
793
+ def _removable_copy(root: str) -> bool:
794
+ """True only for a per-file share directory this module created."""
795
+ if not root:
796
+ return False
797
+ public_root = os.path.realpath(_public_root())
798
+ resolved = os.path.realpath(root)
799
+ return resolved != public_root and _inside(resolved, public_root)
800
+
801
+
802
+ def _drop_locked(registry: Dict[str, Dict[str, Any]], slug: str) -> None:
803
+ """Remove a share while the registry guard is held (used by overwrite)."""
804
+ share = registry.pop(slug, {})
805
+ _stop_server(slug)
806
+ root = str(share.get("root") or "")
807
+ if share.get("kind") == "file" and _removable_copy(root):
808
+ shutil.rmtree(root, ignore_errors=True)
809
+
810
+
811
+ def _truthy(value: Any) -> bool:
812
+ if isinstance(value, bool):
813
+ return value
814
+ return str(value).strip().lower() in ("1", "true", "yes", "on")