gemini-computer-use 0.2.4 → 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.
- package/README.md +733 -16
- package/bin/cli.js +112 -17
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/agent.cpython-313.pyc +0 -0
- package/core/__pycache__/code_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/mcp_tools.cpython-313.pyc +0 -0
- package/core/__pycache__/schemas.cpython-313.pyc +0 -0
- package/core/__pycache__/server.cpython-313.pyc +0 -0
- package/core/__pycache__/vitals.cpython-313.pyc +0 -0
- package/core/__pycache__/web_share.cpython-313.pyc +0 -0
- package/core/agent.py +503 -18
- package/core/agent_harness.py +9 -2
- package/core/code_cache.py +15 -3
- package/core/device.py +2400 -0
- package/core/domain.py +303 -0
- package/core/mcp_tools.py +1840 -112
- package/core/policies.py +35 -5
- package/core/server.py +78 -32
- package/core/termux.py +916 -0
- package/core/updater.py +1713 -0
- package/core/version.py +31 -0
- package/core/vitals.py +66 -2
- package/core/web_share.py +814 -0
- package/gateway.py +1469 -98
- package/install-gui.cmd +40 -0
- package/install-gui.ps1 +1882 -0
- package/install-gui.strings.json +211 -0
- package/install.ps1 +960 -92
- package/install.sh +1214 -81
- package/ops/doctor.ps1 +735 -0
- package/ops/mesh-domain.sh +86 -0
- package/ops/nginx/README.md +77 -0
- package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
- package/ops/nginx/mesh-domain.vhost.template +58 -0
- package/ops/nginx/render-domain.sh +123 -0
- package/ops/update.ps1 +373 -0
- package/ops/windows/README.md +298 -0
- package/ops/windows/agent-watchdog.ps1 +575 -0
- package/ops/windows/run-hidden.vbs +64 -0
- package/package.json +6 -2
- 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")
|