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
package/core/mcp_tools.py
CHANGED
|
@@ -29,6 +29,7 @@ import fnmatch
|
|
|
29
29
|
import glob as _glob
|
|
30
30
|
import hashlib
|
|
31
31
|
import json
|
|
32
|
+
import locale
|
|
32
33
|
import os
|
|
33
34
|
import re
|
|
34
35
|
import shutil
|
|
@@ -42,6 +43,13 @@ import uuid
|
|
|
42
43
|
from datetime import datetime, timezone
|
|
43
44
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
44
45
|
|
|
46
|
+
#: The public domain is owned by core/domain.py; this module asks for it instead of
|
|
47
|
+
#: keeping a fallback chain of its own. Standard library only, so importing it here
|
|
48
|
+
#: costs nothing and cannot fail for a missing third-party package.
|
|
49
|
+
from core import domain
|
|
50
|
+
from core import updater
|
|
51
|
+
from core import version as version_module
|
|
52
|
+
|
|
45
53
|
# ---------------------------------------------------------------------------
|
|
46
54
|
# Optional dependency: the host vitals collector. Kept optional so the module
|
|
47
55
|
# stays importable under a bare interpreter.
|
|
@@ -51,6 +59,33 @@ try: # pragma: no cover - exercised implicitly by the environment
|
|
|
51
59
|
except Exception: # pragma: no cover
|
|
52
60
|
_core_get_host_vitals = None # type: ignore
|
|
53
61
|
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Optional dependencies of the device branch.
|
|
64
|
+
#
|
|
65
|
+
# ``core/device.py`` is the platform telemetry layer and ``core/termux.py`` the
|
|
66
|
+
# Termux:API adapter. Both are standard-library-only, but they are imported
|
|
67
|
+
# defensively for the same reason as the vitals collector above: a checkout that
|
|
68
|
+
# is missing one file must still serve every non-device tool instead of failing
|
|
69
|
+
# to import at all.
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
try: # pragma: no cover - the file ships with the node
|
|
72
|
+
from core import device as _device # type: ignore
|
|
73
|
+
except Exception: # pragma: no cover
|
|
74
|
+
_device = None # type: ignore
|
|
75
|
+
|
|
76
|
+
try: # pragma: no cover - the file ships with the node
|
|
77
|
+
from core import termux as _termux # type: ignore
|
|
78
|
+
except Exception: # pragma: no cover
|
|
79
|
+
_termux = None # type: ignore
|
|
80
|
+
|
|
81
|
+
# Optional: the public file-share server. Not every checkout carries it, so the
|
|
82
|
+
# import is guarded - when it is present it is configured with the same public
|
|
83
|
+
# domain as everything else, from core/domain.py.
|
|
84
|
+
try: # pragma: no cover - core/web_share.py is optional
|
|
85
|
+
from core import web_share as _web_share # type: ignore
|
|
86
|
+
except Exception: # pragma: no cover
|
|
87
|
+
_web_share = None # type: ignore
|
|
88
|
+
|
|
54
89
|
|
|
55
90
|
# ---------------------------------------------------------------------------
|
|
56
91
|
# Platform / command shell
|
|
@@ -66,6 +101,18 @@ if _IS_WINDOWS: # pragma: no cover - Windows only
|
|
|
66
101
|
subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
|
67
102
|
|
|
68
103
|
_SHELL_CACHE: Optional[Tuple[str, List[str]]] = None
|
|
104
|
+
#: The ``MESH_SHELL`` value ``_SHELL_CACHE`` was built from. Caching alone would
|
|
105
|
+
#: freeze whatever the environment said at the first tool call, so a node that
|
|
106
|
+
#: exports ``MESH_SHELL`` later (agent.env is read at startup, but a launcher may
|
|
107
|
+
#: set it after the module is imported) would keep the wrong shell forever.
|
|
108
|
+
_SHELL_CACHE_KEY: Optional[str] = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _shell_override() -> str:
|
|
112
|
+
"""The operator's ``MESH_SHELL`` choice, normalised (empty when unset)."""
|
|
113
|
+
if not _IS_WINDOWS:
|
|
114
|
+
return ""
|
|
115
|
+
return os.environ.get("MESH_SHELL", "").strip().lower()
|
|
69
116
|
|
|
70
117
|
|
|
71
118
|
def _git_bash_windows() -> Optional[str]:
|
|
@@ -88,21 +135,67 @@ def _git_bash_windows() -> Optional[str]:
|
|
|
88
135
|
return None
|
|
89
136
|
|
|
90
137
|
|
|
138
|
+
def powershell_argv() -> Optional[List[str]]:
|
|
139
|
+
"""argv prefix of PowerShell, or None when it is not installed.
|
|
140
|
+
|
|
141
|
+
``pwsh`` (PowerShell 7) wins over ``powershell`` (5.1) when both exist; the
|
|
142
|
+
flags make a one-shot command behave like a script call: no profile, no
|
|
143
|
+
banner, no interactive prompt and no execution-policy surprise.
|
|
144
|
+
"""
|
|
145
|
+
ps = shutil.which("pwsh") or shutil.which("powershell")
|
|
146
|
+
if not ps:
|
|
147
|
+
return None
|
|
148
|
+
return [ps, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_argv() -> List[str]:
|
|
152
|
+
"""argv prefix of cmd.exe (present on every Windows). ``/d`` skips AutoRun,
|
|
153
|
+
``/s`` keeps the quoting of the whole command intact, ``/c`` runs and exits."""
|
|
154
|
+
return [os.environ.get("COMSPEC") or "cmd.exe", "/d", "/s", "/c"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def default_windows_shell() -> Tuple[str, List[str]]:
|
|
158
|
+
"""PowerShell when present, cmd.exe otherwise.
|
|
159
|
+
|
|
160
|
+
Windows never defaults to bash: a stock box has no bash, the ``bash`` on PATH
|
|
161
|
+
is usually the WSL launcher (a different filesystem and process namespace),
|
|
162
|
+
and Git Bash is a third-party extra. Both cmd.exe and PowerShell ship with
|
|
163
|
+
the OS, and PowerShell can do everything cmd.exe can, so it is the default.
|
|
164
|
+
"""
|
|
165
|
+
ps = powershell_argv()
|
|
166
|
+
if ps:
|
|
167
|
+
return ("powershell", ps)
|
|
168
|
+
return ("cmd", cmd_argv())
|
|
169
|
+
|
|
170
|
+
|
|
91
171
|
def command_shell() -> Tuple[str, List[str]]:
|
|
92
|
-
"""(name, argv prefix) of the shell that runs bash_exec / run_job commands.
|
|
93
|
-
|
|
94
|
-
|
|
172
|
+
"""(name, argv prefix) of the shell that runs bash_exec / run_job commands.
|
|
173
|
+
|
|
174
|
+
Windows: PowerShell by default, cmd.exe when PowerShell is missing, bash only
|
|
175
|
+
when the operator asks for it. ``MESH_SHELL`` selects explicitly:
|
|
176
|
+
|
|
177
|
+
MESH_SHELL=cmd cmd.exe (native syntax, no PowerShell startup cost)
|
|
178
|
+
MESH_SHELL=git-bash Git for Windows' bash, when it is installed
|
|
179
|
+
(unset) PowerShell, else cmd.exe
|
|
180
|
+
|
|
181
|
+
The choice is cached per ``MESH_SHELL`` value, so changing the variable (or
|
|
182
|
+
reading it from ``agent.env`` after the module was imported) takes effect.
|
|
183
|
+
"""
|
|
184
|
+
global _SHELL_CACHE, _SHELL_CACHE_KEY
|
|
185
|
+
override = _shell_override()
|
|
186
|
+
if _SHELL_CACHE is None or _SHELL_CACHE_KEY != override:
|
|
95
187
|
if _IS_WINDOWS:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
188
|
+
if override in ("cmd", "cmd.exe"):
|
|
189
|
+
_SHELL_CACHE = ("cmd", cmd_argv())
|
|
190
|
+
elif override in ("git-bash", "gitbash", "bash"):
|
|
191
|
+
bash = _git_bash_windows()
|
|
192
|
+
_SHELL_CACHE = ("git-bash", [bash, "-c"]) if bash else default_windows_shell()
|
|
99
193
|
else:
|
|
100
|
-
|
|
101
|
-
_SHELL_CACHE = ("powershell", [ps, "-NoLogo", "-NoProfile", "-NonInteractive",
|
|
102
|
-
"-ExecutionPolicy", "Bypass", "-Command"])
|
|
194
|
+
_SHELL_CACHE = default_windows_shell()
|
|
103
195
|
else:
|
|
104
196
|
bash = shutil.which("bash")
|
|
105
197
|
_SHELL_CACHE = ("bash", [bash, "-c"]) if bash else ("sh", ["/bin/sh", "-c"])
|
|
198
|
+
_SHELL_CACHE_KEY = override
|
|
106
199
|
return _SHELL_CACHE
|
|
107
200
|
|
|
108
201
|
|
|
@@ -137,6 +230,25 @@ _READ_ONLY = False
|
|
|
137
230
|
_WRITE_ROOTS: Optional[List[str]] = None
|
|
138
231
|
_JOBS_DIR = os.path.realpath(os.path.expanduser(_DEFAULT_JOBS_DIR))
|
|
139
232
|
_MAX_OUTPUT_CHARS = _DEFAULT_MAX_OUTPUT_CHARS
|
|
233
|
+
#: Explicit public base URL for links this node hands out. ``None`` means "ask
|
|
234
|
+
#: core/domain.py", which is the normal case.
|
|
235
|
+
_PUBLIC_URL: Optional[str] = None
|
|
236
|
+
|
|
237
|
+
#: Device branch switches (``MESH_DEVICE*``, see :func:`configure`):
|
|
238
|
+
#: ``_DEVICE_ENABLED`` is ``None`` for "auto" (the branch answers everywhere and
|
|
239
|
+
#: the phone-only actions explain themselves on a desktop), ``False`` when the
|
|
240
|
+
#: operator turned it off, ``True`` when it was forced on.
|
|
241
|
+
_DEVICE_ENABLED: Optional[bool] = None
|
|
242
|
+
#: Optional allowlist of action names for the three action tools. ``None`` means
|
|
243
|
+
#: every action the build implements.
|
|
244
|
+
_DEVICE_ACTIONS: Optional[List[str]] = None
|
|
245
|
+
#: SMS / call log / contacts / placing a call. Off by default: the phone is often
|
|
246
|
+
#: a shared device and this is the operator's private data.
|
|
247
|
+
_DEVICE_PIM = False
|
|
248
|
+
#: Keep ``system_info`` instant by skipping network/cameras/microphones/sensors.
|
|
249
|
+
_DEVICE_QUICK = False
|
|
250
|
+
#: Wall-clock budget for the device blocks inside ``system_info``.
|
|
251
|
+
DEVICE_DEADLINE = 7.0
|
|
140
252
|
|
|
141
253
|
_META_LOCK = threading.RLock()
|
|
142
254
|
_JOBS: Dict[str, Dict[str, Any]] = {}
|
|
@@ -175,9 +287,18 @@ def configure(**kwargs: Any) -> None:
|
|
|
175
287
|
"""(Re)configure the module. All keys are optional.
|
|
176
288
|
|
|
177
289
|
Recognised keys: ``workspace``, ``read_only``, ``allow_write``,
|
|
178
|
-
``write_roots``, ``jobs_dir``, ``max_output_chars
|
|
290
|
+
``write_roots``, ``jobs_dir``, ``max_output_chars``, ``public_url``,
|
|
291
|
+
the web-share keys ``web_dir``, ``mesh_user``, ``max_share_bytes``,
|
|
292
|
+
``max_shares``, ``web_listing``, and the device-branch keys ``device``
|
|
293
|
+
(``auto``/``0``/``1``), ``device_actions``, ``capture_dir``, ``device_pim``
|
|
294
|
+
and ``device_quick``.
|
|
295
|
+
|
|
296
|
+
``public_url`` overrides the public domain for links this node publishes;
|
|
297
|
+
leaving it out keeps the value that :mod:`core.domain` resolves (environment,
|
|
298
|
+
then the domain file, then the one default).
|
|
179
299
|
"""
|
|
180
300
|
global _WORKSPACE, _READ_ONLY, _WRITE_ROOTS, _JOBS_DIR, _MAX_OUTPUT_CHARS
|
|
301
|
+
global _PUBLIC_URL, _DEVICE_ENABLED, _DEVICE_ACTIONS, _DEVICE_PIM, _DEVICE_QUICK
|
|
181
302
|
|
|
182
303
|
if kwargs.get("workspace"):
|
|
183
304
|
_WORKSPACE = os.path.realpath(os.path.expanduser(str(kwargs["workspace"])))
|
|
@@ -196,6 +317,72 @@ def configure(**kwargs: Any) -> None:
|
|
|
196
317
|
_MAX_OUTPUT_CHARS = value
|
|
197
318
|
except (TypeError, ValueError):
|
|
198
319
|
pass
|
|
320
|
+
if "public_url" in kwargs:
|
|
321
|
+
_PUBLIC_URL = domain.normalise_public_base_url(kwargs.get("public_url")) or None
|
|
322
|
+
|
|
323
|
+
# --- device branch ------------------------------------------------------
|
|
324
|
+
# ``device=auto`` (the default) keeps the branch advertised everywhere: the
|
|
325
|
+
# tool surface is declared by the gateway for EVERY node, so a phone tool that
|
|
326
|
+
# vanished on a desktop would break the node/gateway contract. What changes per
|
|
327
|
+
# platform is the answer, not the presence of the tool.
|
|
328
|
+
if "device" in kwargs and kwargs["device"] is not None:
|
|
329
|
+
value = kwargs["device"]
|
|
330
|
+
if isinstance(value, bool):
|
|
331
|
+
_DEVICE_ENABLED = value
|
|
332
|
+
else:
|
|
333
|
+
text = str(value).strip().lower()
|
|
334
|
+
if text in ("auto", ""):
|
|
335
|
+
_DEVICE_ENABLED = None
|
|
336
|
+
elif text in ("1", "true", "yes", "on", "y", "t"):
|
|
337
|
+
_DEVICE_ENABLED = True
|
|
338
|
+
else:
|
|
339
|
+
_DEVICE_ENABLED = False
|
|
340
|
+
if "device_actions" in kwargs:
|
|
341
|
+
raw = kwargs.get("device_actions")
|
|
342
|
+
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
|
343
|
+
_DEVICE_ACTIONS = None
|
|
344
|
+
else:
|
|
345
|
+
parts = raw.replace(";", ",").split(",") if isinstance(raw, str) else list(raw)
|
|
346
|
+
actions = [str(part).strip().lower() for part in parts if str(part).strip()]
|
|
347
|
+
_DEVICE_ACTIONS = actions or None
|
|
348
|
+
if "device_pim" in kwargs and kwargs["device_pim"] is not None:
|
|
349
|
+
_DEVICE_PIM = _parse_bool(kwargs["device_pim"])
|
|
350
|
+
if "device_quick" in kwargs and kwargs["device_quick"] is not None:
|
|
351
|
+
_DEVICE_QUICK = _parse_bool(kwargs["device_quick"])
|
|
352
|
+
if "capture_dir" in kwargs and _device is not None:
|
|
353
|
+
try:
|
|
354
|
+
_device.configure(capture_dir=kwargs.get("capture_dir") or None)
|
|
355
|
+
except Exception:
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
# The public file-share server publishes links; it takes its base from the same
|
|
359
|
+
# single source as everything else (core/web_share.py ships only with some
|
|
360
|
+
# checkouts, hence the guard). Only an *explicit* ``public_url`` is pushed down:
|
|
361
|
+
# with no override web_share asks core.domain itself, so editing MESH_PUBLIC_URL
|
|
362
|
+
# or domain.env moves share links without re-configuring anything, and no second
|
|
363
|
+
# copy of the fallback chain appears here. An empty string means "no override",
|
|
364
|
+
# which is web_share's reset value.
|
|
365
|
+
if _web_share is not None:
|
|
366
|
+
try:
|
|
367
|
+
_web_share.configure(
|
|
368
|
+
web_dir=kwargs.get("web_dir"),
|
|
369
|
+
public_url=_PUBLIC_URL or "",
|
|
370
|
+
user=kwargs.get("mesh_user"),
|
|
371
|
+
max_bytes=kwargs.get("max_share_bytes"),
|
|
372
|
+
max_shares=kwargs.get("max_shares"),
|
|
373
|
+
listing=kwargs.get("web_listing"),
|
|
374
|
+
)
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def public_base_url() -> str:
|
|
380
|
+
"""Public base URL this node publishes, e.g. ``https://mesh.example.com``.
|
|
381
|
+
|
|
382
|
+
An explicit ``configure(public_url=...)`` wins; otherwise the answer comes from
|
|
383
|
+
:mod:`core.domain`, so gateway, node and share links never disagree.
|
|
384
|
+
"""
|
|
385
|
+
return _PUBLIC_URL or domain.public_base_url()
|
|
199
386
|
|
|
200
387
|
|
|
201
388
|
# ---------------------------------------------------------------------------
|
|
@@ -287,6 +474,114 @@ def _sanitize_output(text: str) -> str:
|
|
|
287
474
|
)
|
|
288
475
|
|
|
289
476
|
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# Child-process output decoding
|
|
479
|
+
#
|
|
480
|
+
# A captured pipe carries bytes, and on Windows the same node can see three
|
|
481
|
+
# different encodings in one session: cmd.exe and PowerShell builtins write the
|
|
482
|
+
# OEM console code page (cp866 on a Russian box, cp437/850 elsewhere), most
|
|
483
|
+
# native tools write the ANSI code page (cp1251 here), and tools that opt into
|
|
484
|
+
# UTF-8 (git, curl, node, python with PYTHONUTF8) write UTF-8. Decoding with
|
|
485
|
+
# ``locale.getpreferredencoding()`` — the default of ``text=True`` — silently
|
|
486
|
+
# turned cp866 ``echo привет`` into ``ЇаЁўҐв`` and UTF-8 output into ``привет``.
|
|
487
|
+
# There is no in-band marker to trust, so try the encodings in order of how
|
|
488
|
+
# unambiguous they are and fall back only when a decode actually fails.
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
_DECODING_CACHE: Optional[List[str]] = None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _windows_code_pages() -> List[str]:
|
|
495
|
+
"""Code pages a Windows child may write: OEM console CP first, then ANSI."""
|
|
496
|
+
pages: List[str] = []
|
|
497
|
+
try:
|
|
498
|
+
import ctypes
|
|
499
|
+
|
|
500
|
+
for getter in ("GetOEMCP", "GetACP"):
|
|
501
|
+
try:
|
|
502
|
+
code_page = int(getattr(ctypes.windll.kernel32, getter)())
|
|
503
|
+
except Exception:
|
|
504
|
+
continue
|
|
505
|
+
if code_page > 0:
|
|
506
|
+
pages.append("cp%d" % code_page)
|
|
507
|
+
except Exception:
|
|
508
|
+
pass
|
|
509
|
+
return pages
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _output_decodings() -> List[str]:
|
|
513
|
+
"""Candidate encodings for captured child output, most likely first.
|
|
514
|
+
|
|
515
|
+
Order matters and single-byte code pages never fail to decode, so this is a
|
|
516
|
+
priority list rather than a real fallback chain:
|
|
517
|
+
|
|
518
|
+
1. UTF-8 - what git, curl, node and (with the environment below) python emit.
|
|
519
|
+
2. The OEM console code page - what cmd.exe and PowerShell write, both for
|
|
520
|
+
their own messages and for the native console tools they launch.
|
|
521
|
+
3. The ANSI code page - the last resort for a program using the ANSI API.
|
|
522
|
+
"""
|
|
523
|
+
candidates = ["utf-8"]
|
|
524
|
+
if _IS_WINDOWS:
|
|
525
|
+
candidates.extend(_windows_code_pages())
|
|
526
|
+
else:
|
|
527
|
+
try:
|
|
528
|
+
candidates.append(locale.getpreferredencoding(False))
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
|
|
532
|
+
unique: List[str] = []
|
|
533
|
+
seen = set()
|
|
534
|
+
for name in candidates:
|
|
535
|
+
if not name:
|
|
536
|
+
continue
|
|
537
|
+
key = name.lower()
|
|
538
|
+
if key in seen:
|
|
539
|
+
continue
|
|
540
|
+
seen.add(key)
|
|
541
|
+
unique.append(name)
|
|
542
|
+
return unique
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _child_env() -> Dict[str, str]:
|
|
546
|
+
"""Environment for a child shell.
|
|
547
|
+
|
|
548
|
+
On Windows a Python child whose stdout is a pipe encodes with the *ANSI* code
|
|
549
|
+
page, which is neither UTF-8 nor the OEM code page the surrounding shell uses.
|
|
550
|
+
Telling python children to speak UTF-8 removes that third, undetectable case;
|
|
551
|
+
an operator's explicit setting still wins.
|
|
552
|
+
"""
|
|
553
|
+
env = dict(os.environ)
|
|
554
|
+
if _IS_WINDOWS:
|
|
555
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
556
|
+
env.setdefault("PYTHONUTF8", "1")
|
|
557
|
+
return env
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _decode_output(data: Any) -> str:
|
|
561
|
+
"""Decode captured child output (bytes) into text without mojibake.
|
|
562
|
+
|
|
563
|
+
Line endings are normalised to ``\\n``: reading bytes instead of using
|
|
564
|
+
``text=True`` would otherwise let a Windows child's ``\\r\\n`` leak into
|
|
565
|
+
paginated output and break cursor stitching.
|
|
566
|
+
"""
|
|
567
|
+
global _DECODING_CACHE
|
|
568
|
+
if data is None:
|
|
569
|
+
return ""
|
|
570
|
+
if isinstance(data, str):
|
|
571
|
+
return data
|
|
572
|
+
if not data:
|
|
573
|
+
return ""
|
|
574
|
+
if _DECODING_CACHE is None:
|
|
575
|
+
_DECODING_CACHE = _output_decodings()
|
|
576
|
+
for encoding in _DECODING_CACHE:
|
|
577
|
+
try:
|
|
578
|
+
text = data.decode(encoding)
|
|
579
|
+
except (UnicodeDecodeError, LookupError):
|
|
580
|
+
continue
|
|
581
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
582
|
+
return data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "\n")
|
|
583
|
+
|
|
584
|
+
|
|
290
585
|
def _paginate(full: str, cursor: Any, max_chars: int) -> Tuple[str, bool, Optional[int]]:
|
|
291
586
|
"""Return ``(chunk, truncated, next_cursor)`` for a character offset cursor."""
|
|
292
587
|
offset = max(0, _safe_int(cursor, 0))
|
|
@@ -457,6 +752,28 @@ def _spool_output(text: str) -> Optional[str]:
|
|
|
457
752
|
# Tool 1-3: legacy tools (kept as-is for compatibility)
|
|
458
753
|
# ---------------------------------------------------------------------------
|
|
459
754
|
|
|
755
|
+
def host_uptime_seconds() -> Optional[float]:
|
|
756
|
+
"""Host uptime in seconds, or None when the platform cannot report it.
|
|
757
|
+
|
|
758
|
+
``/proc/uptime`` only exists on Linux; Windows answers through
|
|
759
|
+
``GetTickCount64`` (milliseconds since boot, immune to the 49-day wrap of the
|
|
760
|
+
32-bit variant).
|
|
761
|
+
"""
|
|
762
|
+
try:
|
|
763
|
+
with open("/proc/uptime", "r") as handle:
|
|
764
|
+
return float(handle.read().split()[0])
|
|
765
|
+
except Exception:
|
|
766
|
+
pass
|
|
767
|
+
if _IS_WINDOWS:
|
|
768
|
+
try:
|
|
769
|
+
import ctypes
|
|
770
|
+
|
|
771
|
+
return float(ctypes.windll.kernel32.GetTickCount64() / 1000.0)
|
|
772
|
+
except Exception:
|
|
773
|
+
return None
|
|
774
|
+
return None
|
|
775
|
+
|
|
776
|
+
|
|
460
777
|
def _tool_mesh_status(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
461
778
|
"""Report that this node is reachable, with hard evidence.
|
|
462
779
|
|
|
@@ -468,12 +785,7 @@ def _tool_mesh_status(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
468
785
|
import socket as _socket
|
|
469
786
|
|
|
470
787
|
now = datetime.datetime.now().astimezone()
|
|
471
|
-
uptime_s =
|
|
472
|
-
try:
|
|
473
|
-
with open("/proc/uptime", "r") as handle:
|
|
474
|
-
uptime_s = float(handle.read().split()[0])
|
|
475
|
-
except Exception:
|
|
476
|
-
pass
|
|
788
|
+
uptime_s = host_uptime_seconds()
|
|
477
789
|
try:
|
|
478
790
|
hostname = _socket.gethostname()
|
|
479
791
|
except Exception:
|
|
@@ -498,9 +810,305 @@ def _tool_mesh_status(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
498
810
|
if uptime_s is not None:
|
|
499
811
|
payload["host_uptime_seconds"] = int(uptime_s)
|
|
500
812
|
payload["host_uptime_human"] = "%dh %dm" % (int(uptime_s // 3600), int((uptime_s % 3600) // 60))
|
|
813
|
+
payload["node_version"] = version_module.__version__
|
|
814
|
+
# What kind of machine answered, and whether it is running on its own battery:
|
|
815
|
+
# the model should know it is talking to a phone on 12% before it starts a
|
|
816
|
+
# build, and this costs one cached block rather than a second tool call.
|
|
817
|
+
if _device is not None and _DEVICE_ENABLED is not False:
|
|
818
|
+
try:
|
|
819
|
+
blocks = _device.collect(sections=["device", "battery"], deadline=3.5)
|
|
820
|
+
device_block = blocks.get("device") or {}
|
|
821
|
+
battery = blocks.get("battery") or {}
|
|
822
|
+
payload["device_class"] = device_block.get("class", "")
|
|
823
|
+
payload["scenario"] = _device_scenario()
|
|
824
|
+
if device_block.get("model"):
|
|
825
|
+
payload["device_model"] = device_block.get("model")
|
|
826
|
+
if battery.get("available"):
|
|
827
|
+
payload["battery_percent"] = battery.get("percent")
|
|
828
|
+
payload["on_battery"] = (battery.get("plugged") == "battery"
|
|
829
|
+
or battery.get("ac_online") is False)
|
|
830
|
+
except Exception:
|
|
831
|
+
pass
|
|
832
|
+
try:
|
|
833
|
+
# Local state only: a status call must never wait on the network.
|
|
834
|
+
state = updater.status()
|
|
835
|
+
payload["update"] = {
|
|
836
|
+
"latest": state.get("latest") or "",
|
|
837
|
+
"update_available": state.get("update_available"),
|
|
838
|
+
"last_check": state.get("last_check") or "",
|
|
839
|
+
"last_applied": state.get("last_applied") or "",
|
|
840
|
+
"auto": state.get("auto"),
|
|
841
|
+
}
|
|
842
|
+
except Exception as exc: # diagnostics never break the answer
|
|
843
|
+
payload["update"] = {"error": str(exc)}
|
|
501
844
|
return payload
|
|
502
845
|
|
|
503
846
|
|
|
847
|
+
def _parse_flag(value: Any, default: bool = False) -> bool:
|
|
848
|
+
if value is None:
|
|
849
|
+
return default
|
|
850
|
+
if isinstance(value, bool):
|
|
851
|
+
return value
|
|
852
|
+
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _tool_mesh_update(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
856
|
+
"""Report, check for, or install a newer release of this node's own code.
|
|
857
|
+
|
|
858
|
+
``status`` reads local state only; ``check`` asks GitHub; ``apply`` downloads
|
|
859
|
+
the release payload, verifies its published SHA-256, swaps it in with a
|
|
860
|
+
backup and restarts the agent (the tunnel drops for a few seconds). Applying
|
|
861
|
+
is refused in read-only mode, where every other mutation is refused too.
|
|
862
|
+
"""
|
|
863
|
+
action = str(args.get("action") or "status").strip().lower()
|
|
864
|
+
force = _parse_flag(args.get("force"))
|
|
865
|
+
if action in ("status", "state", "info"):
|
|
866
|
+
return updater.status()
|
|
867
|
+
if action in ("check", "check_update", "latest"):
|
|
868
|
+
return updater.check_for_update(force=force, offline=_parse_flag(args.get("offline")))
|
|
869
|
+
if action in ("apply", "install", "update", "upgrade"):
|
|
870
|
+
if _READ_ONLY:
|
|
871
|
+
return {"error": "this node is read-only (MESH_READ_ONLY=1); "
|
|
872
|
+
"self-update is disabled"}
|
|
873
|
+
restart = args.get("restart")
|
|
874
|
+
return updater.apply_update(force=force,
|
|
875
|
+
restart=None if restart is None else _parse_flag(restart))
|
|
876
|
+
return {"error": "unknown action %r: use status, check or apply" % action}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# ---------------------------------------------------------------------------
|
|
880
|
+
# system_info: per-platform telemetry and wallpaper
|
|
881
|
+
# ---------------------------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
def system_info_posix_commands() -> Dict[str, str]:
|
|
884
|
+
"""The four telemetry blocks on Linux/macOS."""
|
|
885
|
+
return {
|
|
886
|
+
"disks": "df -h --output=target,size,used,avail,pcent 2>/dev/null | head -8",
|
|
887
|
+
"memory": "free -h 2>/dev/null | head -3",
|
|
888
|
+
"load": "uptime",
|
|
889
|
+
"top_processes": "ps -eo comm,%mem --sort=-%mem 2>/dev/null | head -6",
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def system_info_windows_scripts() -> Dict[str, str]:
|
|
894
|
+
"""The same four blocks as PowerShell scripts.
|
|
895
|
+
|
|
896
|
+
Windows has no ``df``/``free``/``uptime``/``ps``: the POSIX versions came
|
|
897
|
+
back as ``'df' is not recognized as an internal or external command`` and the
|
|
898
|
+
model read that as an error. These are read-only CIM queries. They are run
|
|
899
|
+
through PowerShell explicitly rather than through ``command_shell()``, so
|
|
900
|
+
telemetry keeps working when the node is configured with ``MESH_SHELL=cmd``
|
|
901
|
+
for the user's own commands.
|
|
902
|
+
"""
|
|
903
|
+
size_gb = "($_.Size/1GB)"
|
|
904
|
+
free_gb = "($_.FreeSpace/1GB)"
|
|
905
|
+
return {
|
|
906
|
+
"disks": (
|
|
907
|
+
"Get-CimInstance Win32_LogicalDisk -Filter \"DriveType=3\" | ForEach-Object { "
|
|
908
|
+
"$pct = 0; if ($_.Size -gt 0) { $pct = 100 * $_.FreeSpace / $_.Size }; "
|
|
909
|
+
"'{0} {1:N1}G total, {2:N1}G free ({3:N0}% free)' -f "
|
|
910
|
+
f"$_.DeviceID, {size_gb}, {free_gb}, $pct "
|
|
911
|
+
"} | Out-String"
|
|
912
|
+
),
|
|
913
|
+
"memory": (
|
|
914
|
+
"$os = Get-CimInstance Win32_OperatingSystem; "
|
|
915
|
+
"' total used free' + [Environment]::NewLine + "
|
|
916
|
+
"'Mem: {0,10:N1}G {1,10:N1}G {2,10:N1}G' -f "
|
|
917
|
+
"($os.TotalVisibleMemorySize/1MB), "
|
|
918
|
+
"(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory)/1MB), "
|
|
919
|
+
"($os.FreePhysicalMemory/1MB) | Out-String"
|
|
920
|
+
),
|
|
921
|
+
"load": (
|
|
922
|
+
"$os = Get-CimInstance Win32_OperatingSystem; "
|
|
923
|
+
"$cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; "
|
|
924
|
+
"'CPU load {0:N0}% ; booted {1:yyyy-MM-dd HH:mm} ; up {2:N1} h' -f "
|
|
925
|
+
"$cpu, $os.LastBootUpTime, (New-TimeSpan -Start $os.LastBootUpTime).TotalHours | Out-String"
|
|
926
|
+
),
|
|
927
|
+
"top_processes": (
|
|
928
|
+
"Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 5 "
|
|
929
|
+
"@{n='COMMAND';e={$_.ProcessName}}, @{n='MEM_MB';e={[math]::Round($_.WorkingSet64/1MB,1)}} | "
|
|
930
|
+
"Format-Table -AutoSize | Out-String"
|
|
931
|
+
),
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def system_info_cmd_commands() -> Dict[str, str]:
|
|
936
|
+
"""Last-resort cmd.exe telemetry, used only when PowerShell is absent.
|
|
937
|
+
|
|
938
|
+
``wmic`` is deprecated and missing from the newest Windows builds, so this
|
|
939
|
+
path is a fallback rather than the default; ``tasklist`` always works.
|
|
940
|
+
"""
|
|
941
|
+
return {
|
|
942
|
+
"disks": "wmic logicaldisk where drivetype=3 get DeviceID,Size,FreeSpace",
|
|
943
|
+
"memory": "wmic OS get FreePhysicalMemory,TotalVisibleMemorySize",
|
|
944
|
+
"load": "wmic cpu get loadpercentage",
|
|
945
|
+
"top_processes": "tasklist /fo table /nh",
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def largest_wallpaper_image(path: str) -> str:
|
|
950
|
+
"""Resolve a wallpaper path that may be a package directory.
|
|
951
|
+
|
|
952
|
+
KDE wallpaper packages hold one image per resolution and format (png, jpg,
|
|
953
|
+
webp, avif); Plasma shows the largest one, so that is what is reported.
|
|
954
|
+
"""
|
|
955
|
+
if not path or not os.path.isdir(path):
|
|
956
|
+
return path
|
|
957
|
+
best, best_size = "", 0
|
|
958
|
+
for root, _dirs, files in os.walk(path):
|
|
959
|
+
for name in files:
|
|
960
|
+
if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".avif")):
|
|
961
|
+
candidate = os.path.join(root, name)
|
|
962
|
+
try:
|
|
963
|
+
size = os.path.getsize(candidate)
|
|
964
|
+
except OSError:
|
|
965
|
+
continue
|
|
966
|
+
if size > best_size:
|
|
967
|
+
best, best_size = candidate, size
|
|
968
|
+
return best or path
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def windows_wallpaper() -> str:
|
|
972
|
+
"""Current wallpaper on Windows, read from the registry with ``winreg``.
|
|
973
|
+
|
|
974
|
+
No shell and no extra process: the path lives in
|
|
975
|
+
``HKCU\\Control Panel\\Desktop\\WallPaper`` for a single image, while a
|
|
976
|
+
slideshow leaves that value empty and records the rotation in
|
|
977
|
+
``...\\Explorer\\Wallpapers\\BackgroundHistoryPathList``. When the source file
|
|
978
|
+
is gone the desktop still shows the transcoded copy of it, which is what is
|
|
979
|
+
reported last.
|
|
980
|
+
"""
|
|
981
|
+
try:
|
|
982
|
+
import winreg # Windows only; import guarded so this module loads anywhere
|
|
983
|
+
except Exception:
|
|
984
|
+
return ""
|
|
985
|
+
path = ""
|
|
986
|
+
try:
|
|
987
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Control Panel\Desktop") as key:
|
|
988
|
+
value, _kind = winreg.QueryValueEx(key, "WallPaper")
|
|
989
|
+
path = str(value or "").strip().strip('"')
|
|
990
|
+
except Exception:
|
|
991
|
+
path = ""
|
|
992
|
+
if not path:
|
|
993
|
+
try:
|
|
994
|
+
with winreg.OpenKey(
|
|
995
|
+
winreg.HKEY_CURRENT_USER,
|
|
996
|
+
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Wallpapers",
|
|
997
|
+
) as key:
|
|
998
|
+
history, _kind = winreg.QueryValueEx(key, "BackgroundHistoryPathList")
|
|
999
|
+
for candidate in reversed(list(history or [])):
|
|
1000
|
+
candidate = str(candidate).strip().strip('"')
|
|
1001
|
+
if candidate and os.path.isfile(candidate):
|
|
1002
|
+
path = candidate
|
|
1003
|
+
break
|
|
1004
|
+
except Exception:
|
|
1005
|
+
pass
|
|
1006
|
+
if not path:
|
|
1007
|
+
transcoded = os.path.join(
|
|
1008
|
+
os.environ.get("APPDATA", ""), "Microsoft", "Windows", "Themes", "TranscodedWallpaper")
|
|
1009
|
+
if os.path.isfile(transcoded):
|
|
1010
|
+
path = transcoded
|
|
1011
|
+
return os.path.expandvars(path) if path else ""
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _device_scenario() -> str:
|
|
1015
|
+
"""``termux`` | ``linux`` | ``windows`` | ``darwin`` - asked, never guessed.
|
|
1016
|
+
|
|
1017
|
+
``core/device.py`` owns the decision (Windows first, then the Termux signals,
|
|
1018
|
+
then Darwin, else Linux) so that every tool, the telemetry and the tests all
|
|
1019
|
+
read the same answer. The fallback below exists only so a partial checkout (a
|
|
1020
|
+
device layer without the resolver) still classifies correctly instead of
|
|
1021
|
+
answering "unknown" and refusing every phone action.
|
|
1022
|
+
"""
|
|
1023
|
+
if _device is None:
|
|
1024
|
+
return "unknown"
|
|
1025
|
+
resolver = getattr(_device, "scenario", None)
|
|
1026
|
+
try:
|
|
1027
|
+
if callable(resolver):
|
|
1028
|
+
return str(resolver())
|
|
1029
|
+
except Exception:
|
|
1030
|
+
pass
|
|
1031
|
+
try:
|
|
1032
|
+
if os.name == "nt":
|
|
1033
|
+
return "windows"
|
|
1034
|
+
if _device.is_termux():
|
|
1035
|
+
return "termux"
|
|
1036
|
+
import platform
|
|
1037
|
+
return "darwin" if platform.system() == "Darwin" else "linux"
|
|
1038
|
+
except Exception:
|
|
1039
|
+
return "unknown"
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _device_timeout_hint(blocks: Any) -> Optional[str]:
|
|
1043
|
+
"""One sentence when several phone sections timed out for the same reason.
|
|
1044
|
+
|
|
1045
|
+
A phone whose Termux:API app is missing, never opened, or frozen by battery
|
|
1046
|
+
optimisation makes EVERY ``termux-*`` call wait forever. The report would then
|
|
1047
|
+
be five identical "timed out" blocks and the model would have to infer the one
|
|
1048
|
+
root cause - or worse, tell the user the phone is broken. Naming the cause once
|
|
1049
|
+
is the difference between "the device does not answer" and "open the Termux:API
|
|
1050
|
+
app". Only per-command timeouts count (their reason names the command); a block
|
|
1051
|
+
that missed the overall deadline is a different story and is left alone.
|
|
1052
|
+
"""
|
|
1053
|
+
if not isinstance(blocks, dict):
|
|
1054
|
+
return None
|
|
1055
|
+
timed_out: List[str] = []
|
|
1056
|
+
for name, block in blocks.items():
|
|
1057
|
+
if not isinstance(block, dict) or block.get("available") is not False:
|
|
1058
|
+
continue
|
|
1059
|
+
reason = str(block.get("reason") or "").lower()
|
|
1060
|
+
if "timed out" in reason and "termux-" in reason:
|
|
1061
|
+
timed_out.append(str(name))
|
|
1062
|
+
if len(timed_out) < 2:
|
|
1063
|
+
return None
|
|
1064
|
+
fix = ""
|
|
1065
|
+
for name in timed_out:
|
|
1066
|
+
candidate = blocks.get(name) or {}
|
|
1067
|
+
if isinstance(candidate, dict) and candidate.get("fix"):
|
|
1068
|
+
fix = str(candidate["fix"])
|
|
1069
|
+
break
|
|
1070
|
+
hint = ("%d sections timed out (%s). On an Android node that is usually one cause: "
|
|
1071
|
+
"the Termux:API app is not installed, has never been opened once, or is "
|
|
1072
|
+
"frozen by battery optimisation - every termux-api call then waits forever "
|
|
1073
|
+
"instead of failing."
|
|
1074
|
+
% (len(timed_out), ", ".join(sorted(timed_out))))
|
|
1075
|
+
if fix:
|
|
1076
|
+
hint += " Fix: " + fix
|
|
1077
|
+
return hint
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _device_blocks(quick: Optional[bool] = None) -> Dict[str, Any]:
|
|
1081
|
+
"""Device blocks for ``system_info``: battery, signal, time, cpu, cameras...
|
|
1082
|
+
|
|
1083
|
+
The whole collection runs under :data:`DEVICE_DEADLINE` in one thread pool, so
|
|
1084
|
+
the slowest source (a frozen Termux:API app, ``system_profiler``) cannot stall
|
|
1085
|
+
a status call, and a block that could not be read reports why instead of a
|
|
1086
|
+
fabricated zero.
|
|
1087
|
+
"""
|
|
1088
|
+
if _device is None:
|
|
1089
|
+
return {"device": {"available": False,
|
|
1090
|
+
"reason": "core/device.py is missing from this checkout"}}
|
|
1091
|
+
if _DEVICE_ENABLED is False:
|
|
1092
|
+
return {"device": {"available": False,
|
|
1093
|
+
"reason": "device telemetry is switched off by the operator "
|
|
1094
|
+
"(MESH_DEVICE=0)"}}
|
|
1095
|
+
try:
|
|
1096
|
+
blocks = _device.collect(deadline=DEVICE_DEADLINE,
|
|
1097
|
+
quick=_DEVICE_QUICK if quick is None else bool(quick))
|
|
1098
|
+
except Exception as exc: # telemetry never breaks the summary
|
|
1099
|
+
return {"device": {"available": False,
|
|
1100
|
+
"reason": "device collection failed: %s: %s"
|
|
1101
|
+
% (type(exc).__name__, exc)}}
|
|
1102
|
+
if not isinstance(blocks, dict):
|
|
1103
|
+
return {}
|
|
1104
|
+
blocks = dict(blocks)
|
|
1105
|
+
blocks["scenario"] = _device_scenario()
|
|
1106
|
+
hint = _device_timeout_hint(blocks)
|
|
1107
|
+
if hint:
|
|
1108
|
+
blocks["device_hint"] = hint
|
|
1109
|
+
return blocks
|
|
1110
|
+
|
|
1111
|
+
|
|
504
1112
|
def _tool_system_info(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
505
1113
|
"""A one-call summary of the host.
|
|
506
1114
|
|
|
@@ -512,13 +1120,27 @@ def _tool_system_info(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
512
1120
|
import socket as _socket
|
|
513
1121
|
import subprocess as _sp
|
|
514
1122
|
|
|
515
|
-
|
|
1123
|
+
system = platform.system()
|
|
1124
|
+
|
|
1125
|
+
def run_argv(argv: List[str], timeout: float = 8.0) -> str:
|
|
516
1126
|
try:
|
|
517
|
-
out = _sp.run(
|
|
518
|
-
|
|
1127
|
+
out = _sp.run(argv, capture_output=True, timeout=timeout,
|
|
1128
|
+
creationflags=_POPEN_FLAGS)
|
|
1129
|
+
# Disk labels, adapter names and localized CIM strings are not ASCII:
|
|
1130
|
+
# decode explicitly instead of trusting the locale.
|
|
1131
|
+
text = _decode_output(out.stdout) or _decode_output(out.stderr)
|
|
1132
|
+
return text.strip()
|
|
519
1133
|
except Exception:
|
|
520
1134
|
return ""
|
|
521
1135
|
|
|
1136
|
+
def run_shell(cmd: str) -> str:
|
|
1137
|
+
"""Run one internal command in the node's own command shell."""
|
|
1138
|
+
return run_argv(command_shell()[1] + [cmd])
|
|
1139
|
+
|
|
1140
|
+
def run_powershell(script: str) -> str:
|
|
1141
|
+
argv = powershell_argv()
|
|
1142
|
+
return run_argv(argv + [script], timeout=12.0) if argv else ""
|
|
1143
|
+
|
|
522
1144
|
info: Dict[str, Any] = {
|
|
523
1145
|
"hostname": _socket.gethostname(),
|
|
524
1146
|
"os": platform.platform(),
|
|
@@ -527,65 +1149,615 @@ def _tool_system_info(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
527
1149
|
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
|
|
528
1150
|
"home": os.path.expanduser("~"),
|
|
529
1151
|
"cwd": os.getcwd(),
|
|
530
|
-
"
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
#
|
|
534
|
-
# Bash this is PowerShell, and commands must be written for it.
|
|
1152
|
+
"shell": os.environ.get("SHELL") or (os.environ.get("COMSPEC") if system == "Windows" else ""),
|
|
1153
|
+
# The shell bash_exec / run_job really use: PowerShell (or cmd.exe) on
|
|
1154
|
+
# Windows, never bash unless the operator asked for it, so the commands
|
|
1155
|
+
# the model writes must match this value.
|
|
535
1156
|
"command_shell": command_shell()[0],
|
|
536
|
-
"disks": run("df -h --output=target,size,used,avail,pcent 2>/dev/null | head -8"),
|
|
537
|
-
"memory": run("free -h 2>/dev/null | head -3"),
|
|
538
|
-
"load": run("uptime"),
|
|
539
|
-
"top_processes": run("ps -eo comm,%mem --sort=-%mem 2>/dev/null | head -6"),
|
|
540
1157
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
wallpaper = ""
|
|
547
|
-
kde_cfg = os.path.expanduser("~/.config/plasma-org.kde.plasma.desktop-appletsrc")
|
|
548
|
-
if os.path.isfile(kde_cfg):
|
|
1158
|
+
|
|
1159
|
+
if system == "Windows":
|
|
1160
|
+
# Windows has no XDG_*: report the edition instead, and whether the
|
|
1161
|
+
# session is on the physical console or over RDP (SESSIONNAME is
|
|
1162
|
+
# "Console" or "RDP-Tcp#<n>").
|
|
549
1163
|
try:
|
|
550
|
-
|
|
551
|
-
for line in handle:
|
|
552
|
-
if line.startswith("Image="):
|
|
553
|
-
wallpaper = line.strip()[len("Image="):]
|
|
1164
|
+
edition = platform.win32_edition() # 3.8+, Windows only
|
|
554
1165
|
except Exception:
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if
|
|
560
|
-
|
|
1166
|
+
edition = ""
|
|
1167
|
+
info["desktop"] = " ".join(
|
|
1168
|
+
part for part in ("Windows", platform.release(), edition) if part)
|
|
1169
|
+
info["session_type"] = (os.environ.get("SESSIONNAME") or "console").strip().lower()
|
|
1170
|
+
if powershell_argv():
|
|
1171
|
+
scripts = system_info_windows_scripts()
|
|
1172
|
+
info.update({name: run_powershell(script) for name, script in scripts.items()})
|
|
1173
|
+
else:
|
|
1174
|
+
# No PowerShell at all: cmd.exe still answers most of it.
|
|
1175
|
+
info.update({name: run_shell(cmd) for name, cmd in system_info_cmd_commands().items()})
|
|
1176
|
+
else:
|
|
1177
|
+
info["desktop"] = os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION") or ""
|
|
1178
|
+
info["session_type"] = os.environ.get("XDG_SESSION_TYPE", "")
|
|
1179
|
+
info.update({name: run_shell(cmd) for name, cmd in system_info_posix_commands().items()})
|
|
1180
|
+
|
|
1181
|
+
# Device telemetry (core/device.py): battery and charging state, network and
|
|
1182
|
+
# signal strength, locale, language, time, timezone, CPU, RAM, storage,
|
|
1183
|
+
# cameras, microphones and sensors. This is the half of the answer a phone user
|
|
1184
|
+
# asks for first, and on a laptop it is the battery - so it runs on every
|
|
1185
|
+
# platform, in one deadline, and each block says `available: false` + `reason`
|
|
1186
|
+
# when the platform has no source for it instead of reporting a fake zero.
|
|
1187
|
+
info.update(_device_blocks())
|
|
1188
|
+
|
|
1189
|
+
# Current wallpaper, the usual first question for a desktop node.
|
|
1190
|
+
wallpaper = windows_wallpaper() if system == "Windows" else ""
|
|
1191
|
+
if not wallpaper and system != "Windows":
|
|
1192
|
+
# KDE keeps the last applied value in the appletsrc as
|
|
1193
|
+
# `Image=<path-or-dir>` (sometimes with a file:// prefix); GNOME uses
|
|
1194
|
+
# gsettings. A directory means a wallpaper package, so resolve the image
|
|
1195
|
+
# inside it the same way Plasma does.
|
|
1196
|
+
kde_cfg = os.path.expanduser("~/.config/plasma-org.kde.plasma.desktop-appletsrc")
|
|
1197
|
+
if os.path.isfile(kde_cfg):
|
|
1198
|
+
try:
|
|
1199
|
+
with open(kde_cfg, "r", encoding="utf-8", errors="replace") as handle:
|
|
1200
|
+
for line in handle:
|
|
1201
|
+
if line.startswith("Image="):
|
|
1202
|
+
wallpaper = line.strip()[len("Image="):]
|
|
1203
|
+
except Exception:
|
|
1204
|
+
wallpaper = ""
|
|
1205
|
+
wallpaper = wallpaper.replace("file://", "")
|
|
1206
|
+
if not wallpaper:
|
|
1207
|
+
gnome = run_shell("gsettings get org.gnome.desktop.background picture-uri 2>/dev/null")
|
|
1208
|
+
if gnome and "file://" in gnome:
|
|
1209
|
+
wallpaper = gnome.split("file://", 1)[1].strip().strip("'\"")
|
|
561
1210
|
if wallpaper:
|
|
562
|
-
|
|
563
|
-
# A wallpaper package holds many resolutions and formats (png, jpg,
|
|
564
|
-
# webp, avif); pick the largest image inside it.
|
|
565
|
-
best, best_size = "", 0
|
|
566
|
-
for root, _dirs, files in os.walk(wallpaper):
|
|
567
|
-
for name in files:
|
|
568
|
-
if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".avif")):
|
|
569
|
-
path = os.path.join(root, name)
|
|
570
|
-
try:
|
|
571
|
-
size = os.path.getsize(path)
|
|
572
|
-
except OSError:
|
|
573
|
-
continue
|
|
574
|
-
if size > best_size:
|
|
575
|
-
best, best_size = path, size
|
|
576
|
-
wallpaper = best or wallpaper
|
|
1211
|
+
wallpaper = largest_wallpaper_image(wallpaper)
|
|
577
1212
|
info["wallpaper"] = wallpaper
|
|
578
1213
|
info["wallpaper_exists"] = os.path.isfile(wallpaper)
|
|
579
1214
|
return info
|
|
580
1215
|
|
|
581
1216
|
|
|
582
1217
|
def _tool_system_vitals(args: Dict[str, Any]) -> Any:
|
|
1218
|
+
vitals = None
|
|
583
1219
|
try:
|
|
584
1220
|
if _core_get_host_vitals is not None:
|
|
585
|
-
|
|
1221
|
+
vitals = _core_get_host_vitals()
|
|
586
1222
|
except Exception:
|
|
587
|
-
|
|
588
|
-
|
|
1223
|
+
vitals = None
|
|
1224
|
+
if vitals is None:
|
|
1225
|
+
vitals = _fallback_host_vitals()
|
|
1226
|
+
# A laptop's most important "vital" is its battery, and a phone's is battery
|
|
1227
|
+
# plus temperature: both come from the same cached device block that
|
|
1228
|
+
# system_info already uses, so asking here costs nothing extra.
|
|
1229
|
+
if _device is not None and _DEVICE_ENABLED is not False and isinstance(vitals, dict):
|
|
1230
|
+
try:
|
|
1231
|
+
blocks = _device.collect(sections=["battery", "hardware"], deadline=4.0)
|
|
1232
|
+
except Exception:
|
|
1233
|
+
blocks = {}
|
|
1234
|
+
battery = blocks.get("battery") or {}
|
|
1235
|
+
hardware = blocks.get("hardware") or {}
|
|
1236
|
+
if battery:
|
|
1237
|
+
vitals["battery"] = battery
|
|
1238
|
+
thermal = hardware.get("thermal") or []
|
|
1239
|
+
if thermal:
|
|
1240
|
+
vitals["thermal"] = thermal
|
|
1241
|
+
return vitals
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
# ---------------------------------------------------------------------------
|
|
1245
|
+
# Device branch: report and control the device the node itself runs on
|
|
1246
|
+
# ---------------------------------------------------------------------------
|
|
1247
|
+
# One rule shapes this whole section: the tool surface is advertised by the
|
|
1248
|
+
# gateway for EVERY node (it holds a static copy that must match this file), so a
|
|
1249
|
+
# phone-only tool cannot disappear on a desktop. What differs per platform is the
|
|
1250
|
+
# ANSWER - `available: false` plus the reason and, where one exists, the fix.
|
|
1251
|
+
#
|
|
1252
|
+
# The three action tools execute nothing but `termux-*` argv lists through
|
|
1253
|
+
# core/termux.py: no shell, no command assembled from model input, a hard timeout
|
|
1254
|
+
# per call, and every call guarded by the operator's switches (MESH_DEVICE,
|
|
1255
|
+
# MESH_DEVICE_ACTIONS, MESH_DEVICE_PIM, MESH_READ_ONLY).
|
|
1256
|
+
|
|
1257
|
+
#: The actions each tool accepts. Kept as tuples next to the code that implements
|
|
1258
|
+
#: them, and reused for the allowlist check and the "unknown action" message.
|
|
1259
|
+
DEVICE_CONTROL_ACTIONS: Tuple[str, ...] = (
|
|
1260
|
+
"torch", "vibrate", "volume", "volume_get", "brightness", "tts_speak", "toast",
|
|
1261
|
+
"notify", "notify_list", "notify_remove", "clipboard_get", "clipboard_set",
|
|
1262
|
+
"media", "media_scan", "wakelock", "download", "open", "share", "dialog", "wallpaper",
|
|
1263
|
+
)
|
|
1264
|
+
DEVICE_CAPTURE_ACTIONS: Tuple[str, ...] = (
|
|
1265
|
+
"camera_list", "camera_photo", "mic_record_start", "mic_record_stop",
|
|
1266
|
+
"mic_record_status", "location", "fingerprint", "usb_list", "usb_access",
|
|
1267
|
+
"infrared_frequencies", "infrared_transmit",
|
|
1268
|
+
)
|
|
1269
|
+
DEVICE_MESSAGES_ACTIONS: Tuple[str, ...] = (
|
|
1270
|
+
"sms_list", "sms_send", "call_log", "contacts", "call",
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
#: Audio streams termux-volume understands.
|
|
1274
|
+
_DEVICE_STREAMS = ("music", "call", "alarm", "notification", "ring", "system")
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _device_branch_error() -> Optional[Dict[str, Any]]:
|
|
1278
|
+
"""Why the device branch cannot answer at all, or ``None`` when it can."""
|
|
1279
|
+
if _device is None:
|
|
1280
|
+
return {"available": False,
|
|
1281
|
+
"reason": "the device layer (core/device.py) is missing from this checkout"}
|
|
1282
|
+
if _DEVICE_ENABLED is False:
|
|
1283
|
+
return {"available": False,
|
|
1284
|
+
"reason": "the device tools are switched off by the operator (MESH_DEVICE=0)",
|
|
1285
|
+
"fix": "set MESH_DEVICE=auto (or 1) in agent.env and restart the node"}
|
|
1286
|
+
return None
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _action_allowed(action: str) -> Optional[Dict[str, Any]]:
|
|
1290
|
+
"""Apply the optional ``MESH_DEVICE_ACTIONS`` allowlist."""
|
|
1291
|
+
if _DEVICE_ACTIONS is None:
|
|
1292
|
+
return None
|
|
1293
|
+
if action in _DEVICE_ACTIONS:
|
|
1294
|
+
return None
|
|
1295
|
+
return {"available": False, "action": action,
|
|
1296
|
+
"reason": "the operator restricted the device tools to: %s"
|
|
1297
|
+
% ", ".join(_DEVICE_ACTIONS),
|
|
1298
|
+
"fix": "add this action to MESH_DEVICE_ACTIONS in agent.env, or clear it"}
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def _android_only(action: str) -> Optional[Dict[str, Any]]:
|
|
1302
|
+
"""A phone action on a laptop is an explanation, not a failure."""
|
|
1303
|
+
scenario = _device_scenario()
|
|
1304
|
+
if scenario == "termux":
|
|
1305
|
+
return None
|
|
1306
|
+
return {"available": False, "action": action, "scenario": scenario,
|
|
1307
|
+
"reason": ("this action drives an Android phone through the Termux:API, "
|
|
1308
|
+
"and this node runs on '%s'" % scenario),
|
|
1309
|
+
"fix": ("run the node on the phone itself (docs/TERMUX.md); on a laptop or "
|
|
1310
|
+
"desktop use device_info for telemetry instead")}
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _read_only_refusal(action: str) -> Optional[Dict[str, Any]]:
|
|
1314
|
+
if not _READ_ONLY:
|
|
1315
|
+
return None
|
|
1316
|
+
return {"available": False, "action": action,
|
|
1317
|
+
"reason": ("this node is read-only (MESH_READ_ONLY=1): only observation is "
|
|
1318
|
+
"allowed, and this action changes something on the device")}
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _termux_action(action: str, command: str, argv: Sequence[Any] = (),
|
|
1322
|
+
timeout: float = 10.0, parse: bool = True) -> Dict[str, Any]:
|
|
1323
|
+
"""Run one ``termux-*`` argv through the adapter and shape the answer.
|
|
1324
|
+
|
|
1325
|
+
Every guard is applied here rather than in each action handler, so a new action
|
|
1326
|
+
cannot accidentally skip the allowlist, the platform check or the timeout.
|
|
1327
|
+
"""
|
|
1328
|
+
for guard in (_device_branch_error(), _action_allowed(action), _android_only(action)):
|
|
1329
|
+
if guard:
|
|
1330
|
+
guard = dict(guard)
|
|
1331
|
+
guard.setdefault("action", action)
|
|
1332
|
+
guard.setdefault("command", command)
|
|
1333
|
+
return guard
|
|
1334
|
+
adapter = _termux
|
|
1335
|
+
if adapter is None:
|
|
1336
|
+
return {"available": False, "action": action, "command": command,
|
|
1337
|
+
"reason": "core/termux.py is missing from this checkout"}
|
|
1338
|
+
argv = [str(item) for item in argv]
|
|
1339
|
+
try:
|
|
1340
|
+
runner = adapter.run_json if parse else adapter.run_text
|
|
1341
|
+
result = runner(command, argv, timeout)
|
|
1342
|
+
except Exception as exc:
|
|
1343
|
+
return {"ok": False, "action": action, "command": command, "argv": argv,
|
|
1344
|
+
"reason": "%s: %s" % (type(exc).__name__, exc)}
|
|
1345
|
+
payload: Dict[str, Any] = {"action": action, "command": command, "argv": argv}
|
|
1346
|
+
if result.get("ok"):
|
|
1347
|
+
payload["ok"] = True
|
|
1348
|
+
payload["result"] = result.get("value")
|
|
1349
|
+
if result.get("parsed") is False:
|
|
1350
|
+
payload["note"] = "the command answered plain text, not JSON"
|
|
1351
|
+
if result.get("duration_ms") is not None:
|
|
1352
|
+
payload["duration_ms"] = result.get("duration_ms")
|
|
1353
|
+
return payload
|
|
1354
|
+
payload["ok"] = False
|
|
1355
|
+
payload["reason"] = result.get("reason") or "the command did not answer"
|
|
1356
|
+
for key in ("fix", "missing", "denied", "timeout", "raw"):
|
|
1357
|
+
if result.get(key):
|
|
1358
|
+
payload[key] = result[key]
|
|
1359
|
+
return payload
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def _require_arg(args: Dict[str, Any], name: str, action: str) -> Any:
|
|
1363
|
+
"""Return the argument or a shaped "missing argument" answer (never raises)."""
|
|
1364
|
+
value = args.get(name)
|
|
1365
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
1366
|
+
return None
|
|
1367
|
+
return value
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _missing_arg(action: str, name: str, wants: str) -> Dict[str, Any]:
|
|
1371
|
+
return {"error": "action %r needs %s: %s" % (action, name, wants)}
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _capture_path(args: Dict[str, Any], default_name: str) -> str:
|
|
1375
|
+
"""Where a capture is written: the caller's path, else the capture directory.
|
|
1376
|
+
|
|
1377
|
+
The directory is created here (not by the tool) so the answer can report the
|
|
1378
|
+
real path and the model never has to guess whether the write worked.
|
|
1379
|
+
"""
|
|
1380
|
+
raw = args.get("path")
|
|
1381
|
+
if isinstance(raw, str) and raw.strip():
|
|
1382
|
+
target = _resolve(raw.strip())
|
|
1383
|
+
else:
|
|
1384
|
+
base = _device.capture_dir() if _device is not None else os.path.expanduser("~")
|
|
1385
|
+
target = os.path.join(base, default_name)
|
|
1386
|
+
parent = os.path.dirname(target)
|
|
1387
|
+
if parent:
|
|
1388
|
+
try:
|
|
1389
|
+
os.makedirs(parent, exist_ok=True)
|
|
1390
|
+
except Exception:
|
|
1391
|
+
pass
|
|
1392
|
+
return target
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _file_note(path: str) -> Dict[str, Any]:
|
|
1396
|
+
"""Size and sha256 of a file the node just produced, when it exists."""
|
|
1397
|
+
try:
|
|
1398
|
+
return {"path": path, "bytes": os.path.getsize(path)}
|
|
1399
|
+
except Exception:
|
|
1400
|
+
return {"path": path}
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _tool_device_info(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1404
|
+
"""Full device report: every block, or one of them, on any platform."""
|
|
1405
|
+
guard = _device_branch_error()
|
|
1406
|
+
if guard:
|
|
1407
|
+
return guard
|
|
1408
|
+
section = str(args.get("section") or "all").strip().lower()
|
|
1409
|
+
known = tuple(_device.ALL_SECTIONS)
|
|
1410
|
+
if section not in ("summary", "all") and section not in known:
|
|
1411
|
+
return {"error": "unknown section %r: use %s, summary or all"
|
|
1412
|
+
% (section, ", ".join(known))}
|
|
1413
|
+
quick = _parse_flag(args.get("quick"))
|
|
1414
|
+
fresh = _parse_flag(args.get("fresh"))
|
|
1415
|
+
if section == "summary":
|
|
1416
|
+
try:
|
|
1417
|
+
return _device.summary(quick=quick)
|
|
1418
|
+
except Exception as exc:
|
|
1419
|
+
return {"error": "device summary failed: %s: %s" % (type(exc).__name__, exc)}
|
|
1420
|
+
sensor = args.get("sensor")
|
|
1421
|
+
sections = ["all"] if section == "all" else [section]
|
|
1422
|
+
try:
|
|
1423
|
+
blocks = _device.collect(sections=sections, fresh=fresh, quick=quick,
|
|
1424
|
+
sensor=str(sensor) if sensor else None)
|
|
1425
|
+
except Exception as exc:
|
|
1426
|
+
return {"error": "device collection failed: %s: %s" % (type(exc).__name__, exc)}
|
|
1427
|
+
device_block = blocks.get("device") or {}
|
|
1428
|
+
result = {
|
|
1429
|
+
"scenario": _device_scenario(),
|
|
1430
|
+
"device_class": device_block.get("class", ""),
|
|
1431
|
+
"sections": blocks,
|
|
1432
|
+
}
|
|
1433
|
+
hint = _device_timeout_hint(blocks)
|
|
1434
|
+
if hint:
|
|
1435
|
+
result["hint"] = hint
|
|
1436
|
+
return result
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _tool_device_control(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1440
|
+
"""Reversible device actions (torch, volume, clipboard, notifications...)."""
|
|
1441
|
+
guard = _device_branch_error()
|
|
1442
|
+
if guard:
|
|
1443
|
+
return guard
|
|
1444
|
+
action = str(args.get("action") or "").strip().lower()
|
|
1445
|
+
if action not in DEVICE_CONTROL_ACTIONS:
|
|
1446
|
+
return {"error": "unknown action %r: use %s"
|
|
1447
|
+
% (action, ", ".join(DEVICE_CONTROL_ACTIONS))}
|
|
1448
|
+
guard = _read_only_refusal(action) or _action_allowed(action)
|
|
1449
|
+
if guard:
|
|
1450
|
+
return guard
|
|
1451
|
+
|
|
1452
|
+
if action == "torch":
|
|
1453
|
+
state = "on" if _parse_flag(args.get("on")) else "off"
|
|
1454
|
+
return _termux_action(action, "termux-torch", [state], timeout=8.0)
|
|
1455
|
+
if action == "vibrate":
|
|
1456
|
+
duration = _clamp_int(args.get("value"), 1, 60000, 500)
|
|
1457
|
+
return _termux_action(action, "termux-vibrate", ["-d", duration], timeout=8.0)
|
|
1458
|
+
if action == "volume":
|
|
1459
|
+
value = args.get("value")
|
|
1460
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
1461
|
+
return _missing_arg(action, "value", "a level from 0 to 15")
|
|
1462
|
+
stream = str(args.get("stream") or "music").strip().lower()
|
|
1463
|
+
if stream not in _DEVICE_STREAMS:
|
|
1464
|
+
return {"error": "stream must be one of: %s" % ", ".join(_DEVICE_STREAMS)}
|
|
1465
|
+
level = _clamp_int(value, 0, 15, 0)
|
|
1466
|
+
return _termux_action(action, "termux-volume", ["-s", stream, level], timeout=8.0)
|
|
1467
|
+
if action == "volume_get":
|
|
1468
|
+
return _termux_action(action, "termux-volume", [], timeout=8.0)
|
|
1469
|
+
if action == "brightness":
|
|
1470
|
+
value = args.get("value")
|
|
1471
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
1472
|
+
return _missing_arg(action, "value", "a brightness from 0 to 255")
|
|
1473
|
+
return _termux_action(action, "termux-brightness", [_clamp_int(value, 0, 255, 128)],
|
|
1474
|
+
timeout=8.0)
|
|
1475
|
+
if action in ("tts_speak", "toast"):
|
|
1476
|
+
text = _require_arg(args, "text", action)
|
|
1477
|
+
if text is None:
|
|
1478
|
+
return _missing_arg(action, "text", "the words to speak or show")
|
|
1479
|
+
command = "termux-tts-speak" if action == "tts_speak" else "termux-toast"
|
|
1480
|
+
return _termux_action(action, command, [str(text)], timeout=15.0)
|
|
1481
|
+
if action == "notify":
|
|
1482
|
+
body = _require_arg(args, "text", action)
|
|
1483
|
+
if body is None:
|
|
1484
|
+
return _missing_arg(action, "text", "the notification body")
|
|
1485
|
+
argv: List[Any] = ["--title", str(args.get("title") or "Antigravity Mesh"),
|
|
1486
|
+
"--content", str(body)]
|
|
1487
|
+
if args.get("id") is not None:
|
|
1488
|
+
argv += ["--id", str(args.get("id"))]
|
|
1489
|
+
return _termux_action(action, "termux-notification", argv, timeout=10.0)
|
|
1490
|
+
if action == "notify_list":
|
|
1491
|
+
return _termux_action(action, "termux-notification-list", [], timeout=10.0)
|
|
1492
|
+
if action == "notify_remove":
|
|
1493
|
+
notification_id = _require_arg(args, "id", action)
|
|
1494
|
+
if notification_id is None:
|
|
1495
|
+
return _missing_arg(action, "id", "the id used when the notification was posted")
|
|
1496
|
+
return _termux_action(action, "termux-notification-remove", [notification_id],
|
|
1497
|
+
timeout=8.0)
|
|
1498
|
+
if action == "clipboard_get":
|
|
1499
|
+
return _termux_action(action, "termux-clipboard-get", [], timeout=8.0, parse=False)
|
|
1500
|
+
if action == "clipboard_set":
|
|
1501
|
+
text = _require_arg(args, "text", action)
|
|
1502
|
+
if text is None:
|
|
1503
|
+
return _missing_arg(action, "text", "the text to put on the clipboard")
|
|
1504
|
+
# The text travels as one argv element: it is never interpreted by a shell.
|
|
1505
|
+
return _termux_action(action, "termux-clipboard-set", [str(text)], timeout=8.0,
|
|
1506
|
+
parse=False)
|
|
1507
|
+
if action == "media":
|
|
1508
|
+
mode = str(args.get("text") or args.get("state") or "info").strip().lower()
|
|
1509
|
+
if mode not in ("play", "pause", "stop", "info"):
|
|
1510
|
+
return {"error": "media: pass text=play|pause|stop|info (default info), and "
|
|
1511
|
+
"path=<file> for play"}
|
|
1512
|
+
argv = [mode]
|
|
1513
|
+
if mode == "play":
|
|
1514
|
+
path = _require_arg(args, "path", action)
|
|
1515
|
+
if path is None:
|
|
1516
|
+
return _missing_arg(action, "path", "the media file to play")
|
|
1517
|
+
argv.append(str(path))
|
|
1518
|
+
return _termux_action(action, "termux-media-player", argv, timeout=10.0)
|
|
1519
|
+
if action == "media_scan":
|
|
1520
|
+
path = _require_arg(args, "path", action)
|
|
1521
|
+
if path is None:
|
|
1522
|
+
return _missing_arg(action, "path", "the file to announce to the media scanner")
|
|
1523
|
+
return _termux_action(action, "termux-media-scan", [str(path)], timeout=15.0)
|
|
1524
|
+
if action == "wakelock":
|
|
1525
|
+
state = str(args.get("state") or "status").strip().lower()
|
|
1526
|
+
if state not in ("acquire", "release", "status"):
|
|
1527
|
+
return {"error": "wakelock: state must be acquire, release or status"}
|
|
1528
|
+
# Same explanation as every other phone action: a wake lock on a laptop is
|
|
1529
|
+
# meaningless, and "this node is not a phone" is the honest answer rather
|
|
1530
|
+
# than an adapter-level "command not found".
|
|
1531
|
+
guard = _android_only(action)
|
|
1532
|
+
if guard:
|
|
1533
|
+
guard = dict(guard)
|
|
1534
|
+
guard["command"] = "termux-wake-lock"
|
|
1535
|
+
return guard
|
|
1536
|
+
if _termux is None:
|
|
1537
|
+
return {"available": False, "action": action,
|
|
1538
|
+
"reason": "core/termux.py is missing from this checkout"}
|
|
1539
|
+
try:
|
|
1540
|
+
result = _termux.wake_lock(state)
|
|
1541
|
+
except Exception as exc:
|
|
1542
|
+
return {"ok": False, "action": action,
|
|
1543
|
+
"reason": "%s: %s" % (type(exc).__name__, exc)}
|
|
1544
|
+
payload = dict(result) if isinstance(result, dict) else {"result": result}
|
|
1545
|
+
payload["action"] = action
|
|
1546
|
+
payload["note"] = ("the wake lock keeps Android from freezing background work "
|
|
1547
|
+
"while the screen is off; it is ref-counted and released when "
|
|
1548
|
+
"the last job finishes")
|
|
1549
|
+
return payload
|
|
1550
|
+
if action == "download":
|
|
1551
|
+
url = _require_arg(args, "url", action)
|
|
1552
|
+
if url is None:
|
|
1553
|
+
return _missing_arg(action, "url", "the URL for the system download manager")
|
|
1554
|
+
return _termux_action(action, "termux-download", [str(url)], timeout=15.0)
|
|
1555
|
+
if action in ("open", "share"):
|
|
1556
|
+
path = _require_arg(args, "path", action)
|
|
1557
|
+
url = _require_arg(args, "url", action)
|
|
1558
|
+
if action == "open":
|
|
1559
|
+
if path is None and url is None:
|
|
1560
|
+
return _missing_arg(action, "path or url", "what to open")
|
|
1561
|
+
if path is None:
|
|
1562
|
+
return _termux_action(action, "termux-open-url", [str(url)], timeout=10.0)
|
|
1563
|
+
return _termux_action(action, "termux-open", [str(path)], timeout=10.0)
|
|
1564
|
+
if path is None:
|
|
1565
|
+
return _missing_arg(action, "path", "the file to hand to Android's share sheet")
|
|
1566
|
+
return _termux_action(action, "termux-share", [str(path)], timeout=20.0)
|
|
1567
|
+
if action == "dialog":
|
|
1568
|
+
# The dialog blocks until the user answers on the phone, so it carries its
|
|
1569
|
+
# own timeout and the result says when nothing came back in time.
|
|
1570
|
+
seconds = _clamp_int(args.get("timeout_sec"), 5, 120, 30)
|
|
1571
|
+
argv = ["text", "-t", str(args.get("title") or "Antigravity Mesh"),
|
|
1572
|
+
"-i", str(args.get("text") or "")]
|
|
1573
|
+
result = _termux_action(action, "termux-dialog", argv, timeout=float(seconds) + 5.0)
|
|
1574
|
+
result["note"] = ("a dialog waits for the person holding the phone; nothing is "
|
|
1575
|
+
"returned until they answer or the %ds wait expires" % seconds)
|
|
1576
|
+
return result
|
|
1577
|
+
if action == "wallpaper":
|
|
1578
|
+
path = _require_arg(args, "path", action)
|
|
1579
|
+
url = _require_arg(args, "url", action)
|
|
1580
|
+
if path is None and url is None:
|
|
1581
|
+
return _missing_arg(action, "path or url", "the image to set as wallpaper")
|
|
1582
|
+
if path is None:
|
|
1583
|
+
return _termux_action(action, "termux-wallpaper", ["-u", str(url)], timeout=20.0)
|
|
1584
|
+
return _termux_action(action, "termux-wallpaper", ["-f", str(path)], timeout=20.0)
|
|
1585
|
+
return {"error": "unknown action %r" % action} # unreachable, kept total
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def _tool_device_capture(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1589
|
+
"""Camera, microphone, location, fingerprint, USB and infrared."""
|
|
1590
|
+
guard = _device_branch_error()
|
|
1591
|
+
if guard:
|
|
1592
|
+
return guard
|
|
1593
|
+
action = str(args.get("action") or "").strip().lower()
|
|
1594
|
+
if action not in DEVICE_CAPTURE_ACTIONS:
|
|
1595
|
+
return {"error": "unknown action %r: use %s"
|
|
1596
|
+
% (action, ", ".join(DEVICE_CAPTURE_ACTIONS))}
|
|
1597
|
+
# Even the listing actions are gated: they enumerate the physical sensors of a
|
|
1598
|
+
# device the operator may not be holding right now.
|
|
1599
|
+
guard = _read_only_refusal(action) or _action_allowed(action)
|
|
1600
|
+
if guard:
|
|
1601
|
+
return guard
|
|
1602
|
+
|
|
1603
|
+
if action == "camera_list":
|
|
1604
|
+
return _termux_action(action, "termux-camera-info", [], timeout=8.0)
|
|
1605
|
+
if action == "camera_photo":
|
|
1606
|
+
camera_id = _clamp_int(args.get("camera_id"), 0, 9, 0)
|
|
1607
|
+
path = _capture_path(args, "photo-%s.jpg" % time.strftime("%Y%m%d-%H%M%S"))
|
|
1608
|
+
result = _termux_action(action, "termux-camera-photo",
|
|
1609
|
+
["-c", camera_id, path], timeout=30.0)
|
|
1610
|
+
if result.get("ok"):
|
|
1611
|
+
result.update(_file_note(path))
|
|
1612
|
+
result["note"] = ("the file stays on the node; share_file publishes it "
|
|
1613
|
+
"deliberately, if and when the operator asks")
|
|
1614
|
+
else:
|
|
1615
|
+
# The generated name is the only handle the model has on a failed
|
|
1616
|
+
# attempt - it is what a retry must avoid clobbering and what a human
|
|
1617
|
+
# looks for on the phone. An adapter failure (missing Termux:API, denied
|
|
1618
|
+
# permission, timeout) carries no "available" key, so this must be a
|
|
1619
|
+
# plain else, not a check for the guard shape.
|
|
1620
|
+
result["path"] = path
|
|
1621
|
+
return result
|
|
1622
|
+
if action in ("mic_record_start", "mic_record_stop", "mic_record_status"):
|
|
1623
|
+
path = _capture_path(args, "recording-%s.m4a" % time.strftime("%Y%m%d-%H%M%S"))
|
|
1624
|
+
if action == "mic_record_status":
|
|
1625
|
+
return _termux_action(action, "termux-microphone-record", ["-i"], timeout=8.0)
|
|
1626
|
+
if action == "mic_record_stop":
|
|
1627
|
+
return _termux_action(action, "termux-microphone-record", ["-q"], timeout=8.0)
|
|
1628
|
+
argv: List[Any] = ["-f", path]
|
|
1629
|
+
seconds = args.get("seconds")
|
|
1630
|
+
if isinstance(seconds, (int, float)) and not isinstance(seconds, bool):
|
|
1631
|
+
argv += ["-l", _clamp_int(seconds, 1, 3600, 60)]
|
|
1632
|
+
result = _termux_action(action, "termux-microphone-record", argv, timeout=15.0)
|
|
1633
|
+
if result.get("ok"):
|
|
1634
|
+
result.update(_file_note(path))
|
|
1635
|
+
result["note"] = ("recording runs in the Termux:API app: stop it with "
|
|
1636
|
+
"action=mic_record_stop, and keep the app in the foreground "
|
|
1637
|
+
"on Android 11+")
|
|
1638
|
+
return result
|
|
1639
|
+
if action == "location":
|
|
1640
|
+
provider = str(args.get("provider") or "gps").strip().lower()
|
|
1641
|
+
if provider not in ("gps", "network", "passive"):
|
|
1642
|
+
return {"error": "provider must be gps, network or passive"}
|
|
1643
|
+
result = _termux_action(action, "termux-location", ["-p", provider], timeout=25.0)
|
|
1644
|
+
if result.get("ok"):
|
|
1645
|
+
result["note"] = "location comes from Android; it is as precise as the grant allows"
|
|
1646
|
+
return result
|
|
1647
|
+
if action == "fingerprint":
|
|
1648
|
+
result = _termux_action(action, "termux-fingerprint", [], timeout=60.0)
|
|
1649
|
+
result["note"] = ("only the verdict is returned - no fingerprint data ever reaches "
|
|
1650
|
+
"this node or the gateway")
|
|
1651
|
+
return result
|
|
1652
|
+
if action == "usb_list":
|
|
1653
|
+
return _termux_action(action, "termux-usb", ["-l"], timeout=10.0)
|
|
1654
|
+
if action == "usb_access":
|
|
1655
|
+
path = _require_arg(args, "path", action)
|
|
1656
|
+
if path is None:
|
|
1657
|
+
return _missing_arg(action, "path", "the device path printed by usb_list")
|
|
1658
|
+
# Only the permission request is exposed: `termux-usb -e <program>` would let
|
|
1659
|
+
# a model argument name an arbitrary program to execute, which this node
|
|
1660
|
+
# never does. Real I/O goes through the returned descriptor in a script the
|
|
1661
|
+
# operator writes and runs deliberately.
|
|
1662
|
+
result = _termux_action(action, "termux-usb", ["-r", str(path)], timeout=30.0)
|
|
1663
|
+
result["note"] = ("this asks Android for permission and returns the file "
|
|
1664
|
+
"descriptor; reading and writing the device is deliberately left "
|
|
1665
|
+
"to a script the operator runs themselves")
|
|
1666
|
+
return result
|
|
1667
|
+
if action == "infrared_frequencies":
|
|
1668
|
+
return _termux_action(action, "termux-infrared-frequencies", [], timeout=8.0)
|
|
1669
|
+
if action == "infrared_transmit":
|
|
1670
|
+
frequency = args.get("frequency")
|
|
1671
|
+
pattern = _require_arg(args, "pattern", action)
|
|
1672
|
+
if not isinstance(frequency, (int, float)) or isinstance(frequency, bool):
|
|
1673
|
+
return _missing_arg(action, "frequency", "the carrier frequency in Hz")
|
|
1674
|
+
if pattern is None:
|
|
1675
|
+
return _missing_arg(action, "pattern", "durations in microseconds, e.g. 1000,2000")
|
|
1676
|
+
# Only the whitespace AROUND a separator is forgiven ("1000, 2000"): the
|
|
1677
|
+
# durations themselves are one number each, so stripping every space would
|
|
1678
|
+
# silently turn "1000 2000" into the single pulse "10002000".
|
|
1679
|
+
cleaned = re.sub(r"\s*,\s*", ",", str(pattern).strip())
|
|
1680
|
+
if not re.match(r"^\d+(,\d+)*$", cleaned):
|
|
1681
|
+
return {"error": "pattern must be comma-separated integers (microseconds), "
|
|
1682
|
+
"e.g. 1000,2000"}
|
|
1683
|
+
return _termux_action(action, "termux-infrared-transmit",
|
|
1684
|
+
["-f", _clamp_int(frequency, 1, 1000000, 38000), cleaned],
|
|
1685
|
+
timeout=10.0)
|
|
1686
|
+
return {"error": "unknown action %r" % action} # unreachable, kept total
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def _tool_device_messages(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1690
|
+
"""SMS, call log, contacts and placing a call - private data, opt-in."""
|
|
1691
|
+
guard = _device_branch_error()
|
|
1692
|
+
if guard:
|
|
1693
|
+
return guard
|
|
1694
|
+
action = str(args.get("action") or "").strip().lower()
|
|
1695
|
+
if action not in DEVICE_MESSAGES_ACTIONS:
|
|
1696
|
+
return {"error": "unknown action %r: use %s"
|
|
1697
|
+
% (action, ", ".join(DEVICE_MESSAGES_ACTIONS))}
|
|
1698
|
+
guard = _action_allowed(action)
|
|
1699
|
+
if guard:
|
|
1700
|
+
return guard
|
|
1701
|
+
if not _DEVICE_PIM:
|
|
1702
|
+
return {"available": False, "action": action,
|
|
1703
|
+
"reason": ("messages, contacts and the call log are the operator's private "
|
|
1704
|
+
"data, so they are off until the operator enables them on the node"),
|
|
1705
|
+
"fix": ("set MESH_DEVICE_PIM=1 in agent.env and restart the node; grant "
|
|
1706
|
+
"Termux:API the SMS, call-log and contacts permissions in Android "
|
|
1707
|
+
"settings")}
|
|
1708
|
+
if action in ("sms_send", "call"):
|
|
1709
|
+
guard = _read_only_refusal(action)
|
|
1710
|
+
if guard:
|
|
1711
|
+
return guard
|
|
1712
|
+
|
|
1713
|
+
if action == "sms_list":
|
|
1714
|
+
limit = _clamp_int(args.get("limit"), 1, 50, 10)
|
|
1715
|
+
argv: List[Any] = ["-l", limit]
|
|
1716
|
+
if args.get("offset") is not None:
|
|
1717
|
+
argv += ["-o", _clamp_int(args.get("offset"), 0, 10000, 0)]
|
|
1718
|
+
if args.get("type"):
|
|
1719
|
+
argv += ["-t", str(args.get("type"))]
|
|
1720
|
+
return _termux_action(action, "termux-sms-list", argv, timeout=15.0)
|
|
1721
|
+
if action == "sms_send":
|
|
1722
|
+
number = _require_arg(args, "number", action)
|
|
1723
|
+
text = _require_arg(args, "text", action)
|
|
1724
|
+
if number is None:
|
|
1725
|
+
return _missing_arg(action, "number", "the recipient number")
|
|
1726
|
+
if text is None:
|
|
1727
|
+
return _missing_arg(action, "text", "the message body")
|
|
1728
|
+
return _termux_action(action, "termux-sms-send", ["-n", str(number), str(text)],
|
|
1729
|
+
timeout=20.0)
|
|
1730
|
+
if action == "call_log":
|
|
1731
|
+
limit = _clamp_int(args.get("limit"), 1, 50, 10)
|
|
1732
|
+
argv = ["-l", limit]
|
|
1733
|
+
if args.get("offset") is not None:
|
|
1734
|
+
argv += ["-o", _clamp_int(args.get("offset"), 0, 10000, 0)]
|
|
1735
|
+
if args.get("type"):
|
|
1736
|
+
argv += ["-t", str(args.get("type"))]
|
|
1737
|
+
return _termux_action(action, "termux-call-log", argv, timeout=15.0)
|
|
1738
|
+
if action == "contacts":
|
|
1739
|
+
# termux-contact-list has no filter of its own, so the query is applied here
|
|
1740
|
+
# and the (large) answer never has to travel to the client whole.
|
|
1741
|
+
result = _termux_action(action, "termux-contact-list", [], timeout=20.0)
|
|
1742
|
+
if not result.get("ok"):
|
|
1743
|
+
return result
|
|
1744
|
+
limit = _clamp_int(args.get("limit"), 1, 50, 10)
|
|
1745
|
+
query = str(args.get("query") or "").strip().lower()
|
|
1746
|
+
contacts = result.get("result")
|
|
1747
|
+
if not isinstance(contacts, list):
|
|
1748
|
+
return result
|
|
1749
|
+
if query:
|
|
1750
|
+
contacts = [c for c in contacts if isinstance(c, dict)
|
|
1751
|
+
and query in json.dumps(c, ensure_ascii=False).lower()]
|
|
1752
|
+
result["result"] = contacts[:limit]
|
|
1753
|
+
result["total_matches"] = len(contacts)
|
|
1754
|
+
return result
|
|
1755
|
+
if action == "call":
|
|
1756
|
+
number = _require_arg(args, "number", action)
|
|
1757
|
+
if number is None:
|
|
1758
|
+
return _missing_arg(action, "number", "the number to dial")
|
|
1759
|
+
return _termux_action(action, "termux-telephony-call", [str(number)], timeout=15.0)
|
|
1760
|
+
return {"error": "unknown action %r" % action} # unreachable, kept total
|
|
589
1761
|
|
|
590
1762
|
|
|
591
1763
|
def _tool_get_orchestration_skill(args: Dict[str, Any]) -> Any:
|
|
@@ -633,33 +1805,42 @@ def _tool_list_dir(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
633
1805
|
def _run_bash(command: str, timeout: int) -> Tuple[str, str, int, float]:
|
|
634
1806
|
started = time.time()
|
|
635
1807
|
try:
|
|
1808
|
+
# Bytes, not text: the child's encoding is discovered with
|
|
1809
|
+
# _decode_output() instead of assumed from the locale (see above).
|
|
636
1810
|
proc = subprocess.run(
|
|
637
1811
|
_shell_argv(command),
|
|
638
1812
|
cwd=_WORKSPACE if os.path.isdir(_WORKSPACE) else None,
|
|
639
1813
|
stdin=subprocess.DEVNULL,
|
|
640
1814
|
capture_output=True,
|
|
641
|
-
text=True,
|
|
642
1815
|
timeout=timeout,
|
|
643
|
-
errors="replace",
|
|
644
1816
|
creationflags=_POPEN_FLAGS,
|
|
1817
|
+
env=_child_env(),
|
|
1818
|
+
)
|
|
1819
|
+
return (
|
|
1820
|
+
_decode_output(proc.stdout),
|
|
1821
|
+
_decode_output(proc.stderr),
|
|
1822
|
+
proc.returncode,
|
|
1823
|
+
round(time.time() - started, 3),
|
|
645
1824
|
)
|
|
646
|
-
return proc.stdout or "", proc.stderr or "", proc.returncode, round(time.time() - started, 3)
|
|
647
1825
|
except subprocess.TimeoutExpired as exc:
|
|
648
|
-
out = exc.stdout
|
|
649
|
-
err = exc.stderr
|
|
650
|
-
if isinstance(out, bytes):
|
|
651
|
-
out = out.decode("utf-8", "replace")
|
|
652
|
-
if isinstance(err, bytes):
|
|
653
|
-
err = err.decode("utf-8", "replace")
|
|
1826
|
+
out = _decode_output(exc.stdout)
|
|
1827
|
+
err = _decode_output(exc.stderr)
|
|
654
1828
|
err = (err + "\n" if err else "") + (
|
|
655
|
-
"Command timed out after %d seconds. Tip: for long
|
|
656
|
-
"
|
|
1829
|
+
"Command timed out after %d seconds. Tip: for long tasks start them with "
|
|
1830
|
+
"run_job instead of bash_exec.%s" % (timeout, _background_hint())
|
|
657
1831
|
)
|
|
658
1832
|
return out, err, 124, round(time.time() - started, 3)
|
|
659
1833
|
except Exception as exc:
|
|
660
1834
|
return "", str(exc), 1, round(time.time() - started, 3)
|
|
661
1835
|
|
|
662
1836
|
|
|
1837
|
+
def _background_hint() -> str:
|
|
1838
|
+
"""The shell-correct way to leave something running on this host."""
|
|
1839
|
+
if _IS_WINDOWS:
|
|
1840
|
+
return " There is no '&' background operator in PowerShell or cmd."
|
|
1841
|
+
return " On POSIX: 'nohup <command> > output.log 2>&1 &'."
|
|
1842
|
+
|
|
1843
|
+
|
|
663
1844
|
def _tool_bash_exec(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
664
1845
|
command = args.get("command")
|
|
665
1846
|
if not isinstance(command, str) or not command.strip():
|
|
@@ -787,7 +1968,34 @@ def _tool_write_file(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
787
1968
|
|
|
788
1969
|
data = content.encode("utf-8", "replace")
|
|
789
1970
|
_atomic_write_bytes(target, data, mode=mode)
|
|
790
|
-
|
|
1971
|
+
result: Dict[str, Any] = {
|
|
1972
|
+
"ok": True,
|
|
1973
|
+
"path": target,
|
|
1974
|
+
"bytes": len(data),
|
|
1975
|
+
"sha256": _sha256_hex(data),
|
|
1976
|
+
}
|
|
1977
|
+
if mode is not None:
|
|
1978
|
+
# Report what the filesystem actually kept. On Windows a 0755 request
|
|
1979
|
+
# silently becomes "read-write, no execute bit" because the platform
|
|
1980
|
+
# has no POSIX mode bits — claiming success would make the model
|
|
1981
|
+
# believe it produced an executable script.
|
|
1982
|
+
effective: Optional[int] = None
|
|
1983
|
+
try:
|
|
1984
|
+
effective = os.stat(target).st_mode & 0o777
|
|
1985
|
+
except OSError:
|
|
1986
|
+
pass
|
|
1987
|
+
result["mode"] = ("0%o" % effective) if effective is not None else None
|
|
1988
|
+
if _IS_WINDOWS:
|
|
1989
|
+
result["mode_applied"] = False
|
|
1990
|
+
result["mode_note"] = (
|
|
1991
|
+
"Windows has no POSIX permission bits: chmod only toggles the "
|
|
1992
|
+
"read-only attribute, so the execute bit was not set. Run scripts "
|
|
1993
|
+
"through their interpreter (python script.py) or give them a "
|
|
1994
|
+
"recognised extension (.ps1/.bat/.cmd)."
|
|
1995
|
+
)
|
|
1996
|
+
else:
|
|
1997
|
+
result["mode_applied"] = effective == mode
|
|
1998
|
+
return result
|
|
791
1999
|
except Exception as exc:
|
|
792
2000
|
return {"error": "write_file failed: %s" % exc}
|
|
793
2001
|
|
|
@@ -1109,14 +2317,26 @@ def _pid_alive_windows(pid: int) -> bool:
|
|
|
1109
2317
|
"""
|
|
1110
2318
|
try:
|
|
1111
2319
|
import ctypes
|
|
2320
|
+
from ctypes import wintypes
|
|
2321
|
+
|
|
1112
2322
|
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
2323
|
+
# Declare the signatures: a HANDLE is pointer-sized, and letting ctypes
|
|
2324
|
+
# default to c_int truncates it on 64-bit, so CloseHandle would receive a
|
|
2325
|
+
# value that is not the handle OpenProcess returned.
|
|
2326
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
2327
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
2328
|
+
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
|
2329
|
+
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
2330
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
2331
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
2332
|
+
|
|
1113
2333
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
1114
2334
|
STILL_ACTIVE = 259
|
|
1115
2335
|
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
|
|
1116
2336
|
if not handle:
|
|
1117
2337
|
return False
|
|
1118
2338
|
try:
|
|
1119
|
-
code =
|
|
2339
|
+
code = wintypes.DWORD()
|
|
1120
2340
|
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
1121
2341
|
return False
|
|
1122
2342
|
return code.value == STILL_ACTIVE
|
|
@@ -1236,6 +2456,55 @@ def _pump(stream, path: str, budget: _OutputBudget) -> None:
|
|
|
1236
2456
|
pass
|
|
1237
2457
|
|
|
1238
2458
|
|
|
2459
|
+
def _wake_lock_for_job() -> Optional[bool]:
|
|
2460
|
+
"""Hold Android's partial wake lock while a background job runs.
|
|
2461
|
+
|
|
2462
|
+
Android freezes a background app once the screen goes off, which turns a
|
|
2463
|
+
perfectly healthy build into a job that never finishes. The installer's boot
|
|
2464
|
+
script takes the lock once at boot, but nothing re-checks it afterwards, so a
|
|
2465
|
+
job start is the honest place to make sure it is held. Returns ``True`` when a
|
|
2466
|
+
lock is held for this job, ``False`` when the phone refused, and ``None`` on a
|
|
2467
|
+
platform where the question does not apply.
|
|
2468
|
+
"""
|
|
2469
|
+
if _termux is None or _device_scenario() != "termux":
|
|
2470
|
+
return None
|
|
2471
|
+
try:
|
|
2472
|
+
result = _termux.wake_lock("acquire")
|
|
2473
|
+
except Exception:
|
|
2474
|
+
return False
|
|
2475
|
+
return bool(result.get("held") or result.get("count"))
|
|
2476
|
+
|
|
2477
|
+
|
|
2478
|
+
#: Jobs whose wake lock has already been released in this process. Both the reaper
|
|
2479
|
+
#: and job_kill finalise the same job (a killed job is reaped too), and a second
|
|
2480
|
+
#: release is not harmless: the lock is ref-counted once per job, so releasing twice
|
|
2481
|
+
#: unlocks a *different, still-running* job on the same phone.
|
|
2482
|
+
_WAKE_LOCK_RELEASED: set = set()
|
|
2483
|
+
|
|
2484
|
+
|
|
2485
|
+
def _release_job_wake_lock(job_id: str) -> None:
|
|
2486
|
+
"""Release the wake lock this job took, at most once per job.
|
|
2487
|
+
|
|
2488
|
+
The guard lives in memory because both callers are threads of this process; the
|
|
2489
|
+
metadata write only records the fact so ``job_list`` stops claiming the job
|
|
2490
|
+
still holds it.
|
|
2491
|
+
"""
|
|
2492
|
+
if _termux is None:
|
|
2493
|
+
return
|
|
2494
|
+
with _META_LOCK:
|
|
2495
|
+
if job_id in _WAKE_LOCK_RELEASED:
|
|
2496
|
+
return
|
|
2497
|
+
meta = _read_json(_meta_path(job_id)) or {}
|
|
2498
|
+
if not meta.get("wake_lock"):
|
|
2499
|
+
return
|
|
2500
|
+
_WAKE_LOCK_RELEASED.add(job_id)
|
|
2501
|
+
_update_meta(job_id, wake_lock=False)
|
|
2502
|
+
try:
|
|
2503
|
+
_termux.wake_lock("release")
|
|
2504
|
+
except Exception:
|
|
2505
|
+
pass
|
|
2506
|
+
|
|
2507
|
+
|
|
1239
2508
|
def _reap(job_id: str, proc: "subprocess.Popen", started_mono: float) -> None:
|
|
1240
2509
|
"""Background thread: collect a finished job and persist its result."""
|
|
1241
2510
|
try:
|
|
@@ -1250,26 +2519,32 @@ def _reap(job_id: str, proc: "subprocess.Popen", started_mono: float) -> None:
|
|
|
1250
2519
|
except Exception:
|
|
1251
2520
|
pass
|
|
1252
2521
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
"
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
2522
|
+
# The wake lock is released in a `finally`: a job that finished must not keep
|
|
2523
|
+
# Android awake because its metadata could not be read back (the early return
|
|
2524
|
+
# below used to leak exactly that way).
|
|
2525
|
+
try:
|
|
2526
|
+
with _META_LOCK:
|
|
2527
|
+
meta = _read_json(_meta_path(job_id))
|
|
2528
|
+
if meta is None:
|
|
2529
|
+
return
|
|
2530
|
+
if meta.get("status") == "killed":
|
|
2531
|
+
status = "killed"
|
|
2532
|
+
else:
|
|
2533
|
+
status = "done"
|
|
2534
|
+
meta.update(
|
|
2535
|
+
{
|
|
2536
|
+
"status": status,
|
|
2537
|
+
"exit_code": returncode,
|
|
2538
|
+
"duration": round(time.time() - started_mono, 3),
|
|
2539
|
+
"finished_at": _now_iso(),
|
|
2540
|
+
}
|
|
2541
|
+
)
|
|
2542
|
+
try:
|
|
2543
|
+
_write_json_atomic(_meta_path(job_id), meta)
|
|
2544
|
+
except Exception:
|
|
2545
|
+
pass
|
|
2546
|
+
finally:
|
|
2547
|
+
_release_job_wake_lock(job_id)
|
|
1273
2548
|
|
|
1274
2549
|
|
|
1275
2550
|
def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -1308,6 +2583,7 @@ def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1308
2583
|
bufsize=0,
|
|
1309
2584
|
start_new_session=not _IS_WINDOWS,
|
|
1310
2585
|
creationflags=_POPEN_FLAGS,
|
|
2586
|
+
env=_child_env(),
|
|
1311
2587
|
)
|
|
1312
2588
|
except Exception as exc:
|
|
1313
2589
|
return {"error": "failed to start job: %s" % exc}
|
|
@@ -1321,6 +2597,10 @@ def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1321
2597
|
for thread in readers:
|
|
1322
2598
|
thread.start()
|
|
1323
2599
|
started_mono = time.time()
|
|
2600
|
+
# On a phone the lock is taken for as long as this job runs and released in
|
|
2601
|
+
# _reap / job_kill. It is deliberately not an argument: a model that forgot
|
|
2602
|
+
# to pass it would produce a job that silently stops when the screen does.
|
|
2603
|
+
wake_lock = _wake_lock_for_job()
|
|
1324
2604
|
_JOBS[job_id] = {
|
|
1325
2605
|
"proc": proc,
|
|
1326
2606
|
"readers": readers,
|
|
@@ -1341,24 +2621,40 @@ def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1341
2621
|
"finished_at": None,
|
|
1342
2622
|
"stdout_path": stdout_path,
|
|
1343
2623
|
"stderr_path": stderr_path,
|
|
2624
|
+
"wake_lock": bool(wake_lock),
|
|
1344
2625
|
}
|
|
1345
2626
|
try:
|
|
1346
2627
|
_write_json_atomic(_meta_path(job_id), meta)
|
|
1347
2628
|
except Exception as exc:
|
|
1348
2629
|
_JOBS.pop(job_id, None)
|
|
2630
|
+
# The job never really started from the caller's point of view, so the
|
|
2631
|
+
# lock taken for it must not outlive the attempt.
|
|
2632
|
+
if wake_lock:
|
|
2633
|
+
try:
|
|
2634
|
+
_termux.wake_lock("release") # type: ignore[union-attr]
|
|
2635
|
+
except Exception:
|
|
2636
|
+
pass
|
|
1349
2637
|
return {"error": "failed to persist job metadata: %s" % exc}
|
|
1350
2638
|
|
|
1351
2639
|
reaper = threading.Thread(target=_reap, args=(job_id, proc, started_mono), daemon=True)
|
|
1352
2640
|
reaper.start()
|
|
1353
2641
|
_JOBS[job_id]["reaper"] = reaper
|
|
1354
2642
|
|
|
1355
|
-
|
|
2643
|
+
result = {
|
|
1356
2644
|
"job_id": job_id,
|
|
1357
2645
|
"pid": proc.pid,
|
|
1358
2646
|
"pid_start": meta.get("pid_start"),
|
|
1359
2647
|
"command": command,
|
|
1360
2648
|
"started_at": started_at,
|
|
1361
2649
|
}
|
|
2650
|
+
if wake_lock is not None:
|
|
2651
|
+
result["wake_lock"] = bool(wake_lock)
|
|
2652
|
+
if not wake_lock:
|
|
2653
|
+
result["wake_lock_note"] = (
|
|
2654
|
+
"Android's wake lock could not be taken, so this job may be frozen "
|
|
2655
|
+
"when the screen turns off; install Termux:API and disable battery "
|
|
2656
|
+
"optimisation for Termux (docs/TERMUX.md)")
|
|
2657
|
+
return result
|
|
1362
2658
|
except Exception as exc:
|
|
1363
2659
|
return {"error": "run_job failed: %s" % exc}
|
|
1364
2660
|
|
|
@@ -1403,8 +2699,11 @@ def _tool_job_output(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1403
2699
|
|
|
1404
2700
|
def _read_text(path: str) -> str:
|
|
1405
2701
|
try:
|
|
1406
|
-
|
|
1407
|
-
|
|
2702
|
+
# The pump stores raw bytes: the child's encoding is discovered
|
|
2703
|
+
# here, the same way bash_exec decodes its own capture. Reading
|
|
2704
|
+
# as UTF-8 only would mangle a cmd.exe job on a non-English box.
|
|
2705
|
+
with open(path, "rb") as handle:
|
|
2706
|
+
return _sanitize_output(_decode_output(handle.read()))
|
|
1408
2707
|
except Exception:
|
|
1409
2708
|
return ""
|
|
1410
2709
|
|
|
@@ -1445,10 +2744,22 @@ if _IS_WINDOWS:
|
|
|
1445
2744
|
|
|
1446
2745
|
def _kill_tree_windows(pid: int) -> None:
|
|
1447
2746
|
"""End a job and its children on Windows (there are no process groups to signal)."""
|
|
1448
|
-
proc = subprocess.run(
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
2747
|
+
proc = subprocess.run(
|
|
2748
|
+
["taskkill", "/T", "/F", "/PID", str(int(pid))],
|
|
2749
|
+
stdin=subprocess.DEVNULL,
|
|
2750
|
+
capture_output=True,
|
|
2751
|
+
timeout=15,
|
|
2752
|
+
creationflags=_POPEN_FLAGS,
|
|
2753
|
+
)
|
|
2754
|
+
if proc.returncode == 0:
|
|
2755
|
+
return
|
|
2756
|
+
detail = (_decode_output(proc.stderr) or _decode_output(proc.stdout)).strip()
|
|
2757
|
+
# "ERROR: The process "1234" not found." means the tree is already gone —
|
|
2758
|
+
# the usual case when a short-lived job finishes between job_output and
|
|
2759
|
+
# job_kill. That is success for the caller, not a failure to report.
|
|
2760
|
+
if "not found" in detail.lower() or "не найдено" in detail.lower() or not detail:
|
|
2761
|
+
return
|
|
2762
|
+
raise OSError(detail)
|
|
1452
2763
|
|
|
1453
2764
|
|
|
1454
2765
|
def _tool_job_kill(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -1496,6 +2807,7 @@ def _tool_job_kill(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1496
2807
|
|
|
1497
2808
|
if delivered:
|
|
1498
2809
|
_update_meta(job_id, status="killed", finished_at=_now_iso(), duration=_duration_from_meta(meta))
|
|
2810
|
+
_release_job_wake_lock(job_id)
|
|
1499
2811
|
return {"ok": True, "status": "killed"}
|
|
1500
2812
|
except Exception as exc:
|
|
1501
2813
|
return {"error": "job_kill failed: %s" % exc}
|
|
@@ -1528,6 +2840,9 @@ def _tool_job_list(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1528
2840
|
"exit_code": meta.get("exit_code"),
|
|
1529
2841
|
"duration": meta.get("duration"),
|
|
1530
2842
|
"started_at": meta.get("started_at"),
|
|
2843
|
+
# On a phone this says whether the job is protected from Android's
|
|
2844
|
+
# background freezer while the screen is off.
|
|
2845
|
+
"wake_lock": meta.get("wake_lock", None),
|
|
1531
2846
|
})
|
|
1532
2847
|
return {"jobs": jobs, "count": len(jobs), "total": len(metas),
|
|
1533
2848
|
"note": ("showing the %d most recent of %d; pass limit=<n> for more"
|
|
@@ -1536,6 +2851,63 @@ def _tool_job_list(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1536
2851
|
return {"error": "job_list failed: %s" % exc}
|
|
1537
2852
|
|
|
1538
2853
|
|
|
2854
|
+
# ---------------------------------------------------------------------------
|
|
2855
|
+
# Public web shares (core/web_share.py)
|
|
2856
|
+
#
|
|
2857
|
+
# ``share_file``/``serve_dir`` start a real loopback-only static web server on
|
|
2858
|
+
# this node; the gateway relays GET/HEAD from
|
|
2859
|
+
# https://<shared-domain>/<node>/<name>-<secret>/... into it over the tunnel.
|
|
2860
|
+
# The module is optional (an older checkout may not carry it), so every tool
|
|
2861
|
+
# answers an honest "update the node" error instead of raising.
|
|
2862
|
+
#
|
|
2863
|
+
# ``_http_share`` is the internal relay target and is deliberately NOT part of
|
|
2864
|
+
# TOOLS, so it never appears in tools/list and cannot be called by the model.
|
|
2865
|
+
# ---------------------------------------------------------------------------
|
|
2866
|
+
|
|
2867
|
+
def _web_share_error() -> Dict[str, Any]:
|
|
2868
|
+
return {
|
|
2869
|
+
"error": "web sharing is unavailable on this node: core/web_share.py is missing. "
|
|
2870
|
+
"Re-run the installer (or update the node) to get the share tools."
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
|
|
2874
|
+
def _tool_share_file(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
2875
|
+
if _READ_ONLY:
|
|
2876
|
+
return {"error": "MESH_READ_ONLY is set: publishing files is disabled on this node"}
|
|
2877
|
+
if _web_share is None:
|
|
2878
|
+
return _web_share_error()
|
|
2879
|
+
return _web_share.share_file(args.get("path"), args.get("name"), args.get("overwrite", False))
|
|
2880
|
+
|
|
2881
|
+
|
|
2882
|
+
def _tool_serve_dir(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
2883
|
+
if _READ_ONLY:
|
|
2884
|
+
return {"error": "MESH_READ_ONLY is set: publishing directories is disabled on this node"}
|
|
2885
|
+
if _web_share is None:
|
|
2886
|
+
return _web_share_error()
|
|
2887
|
+
return _web_share.serve_dir(args.get("path"), args.get("name"))
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
def _tool_share_list(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
2891
|
+
if _web_share is None:
|
|
2892
|
+
return _web_share_error()
|
|
2893
|
+
return _web_share.share_list()
|
|
2894
|
+
|
|
2895
|
+
|
|
2896
|
+
def _tool_unshare(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
2897
|
+
if _web_share is None:
|
|
2898
|
+
return _web_share_error()
|
|
2899
|
+
target = args.get("name") or args.get("slug") or args.get("url")
|
|
2900
|
+
return _web_share.unshare(target)
|
|
2901
|
+
|
|
2902
|
+
|
|
2903
|
+
def _tool_http_share(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
2904
|
+
"""Internal: serve one public GET/HEAD relayed by the gateway."""
|
|
2905
|
+
if _web_share is None:
|
|
2906
|
+
return {"status": 404, "headers": {"content-type": "text/plain; charset=utf-8"},
|
|
2907
|
+
"body_b64": ""}
|
|
2908
|
+
return _web_share.handle_http_request(args)
|
|
2909
|
+
|
|
2910
|
+
|
|
1539
2911
|
# ---------------------------------------------------------------------------
|
|
1540
2912
|
# Tool definitions (MCP schemas)
|
|
1541
2913
|
# ---------------------------------------------------------------------------
|
|
@@ -1548,25 +2920,142 @@ def _schema(properties: Dict[str, Any], required: Optional[List[str]] = None) ->
|
|
|
1548
2920
|
return schema
|
|
1549
2921
|
|
|
1550
2922
|
|
|
2923
|
+
# ---------------------------------------------------------------------------
|
|
2924
|
+
# MCP tool annotations - when the client is allowed to run without asking
|
|
2925
|
+
# ---------------------------------------------------------------------------
|
|
2926
|
+
# A client such as Gemini Spark reads ``Tool.annotations`` from ``tools/list`` to
|
|
2927
|
+
# decide whether it must stop and ask the user "confirm this action?" before a
|
|
2928
|
+
# call. The spec's defaults are the pessimistic ones (``readOnlyHint`` false,
|
|
2929
|
+
# ``destructiveHint`` true), so a tool advertised with *no* annotations is treated
|
|
2930
|
+
# as if it deleted something - which is exactly why the user was asked to confirm
|
|
2931
|
+
# every single call, while other MCP servers (whose tools do carry hints) ran
|
|
2932
|
+
# without a prompt.
|
|
2933
|
+
#
|
|
2934
|
+
# Four explicit profiles replace that guesswork:
|
|
2935
|
+
#
|
|
2936
|
+
# read-only the tool only observes the host -> never ask
|
|
2937
|
+
# local write it changes something on the host, but nothing is installed or
|
|
2938
|
+
# removed -> do not ask
|
|
2939
|
+
# publish the result is reachable from the internet -> no install/delete
|
|
2940
|
+
# destructive it installs (mesh_update) or deletes/revokes (unshare)
|
|
2941
|
+
# something -> ask first
|
|
2942
|
+
#
|
|
2943
|
+
# The node's own guards are untouched by this: ``MESH_READ_ONLY`` and
|
|
2944
|
+
# ``MESH_WRITE_ROOTS`` still refuse work at the tool implementation level.
|
|
2945
|
+
_ANNOTATIONS_READ_ONLY: Dict[str, Any] = {
|
|
2946
|
+
"readOnlyHint": True,
|
|
2947
|
+
"destructiveHint": False,
|
|
2948
|
+
"idempotentHint": True,
|
|
2949
|
+
"openWorldHint": False,
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
_ANNOTATIONS_LOCAL_WRITE: Dict[str, Any] = {
|
|
2953
|
+
"readOnlyHint": False,
|
|
2954
|
+
"destructiveHint": False,
|
|
2955
|
+
"idempotentHint": False,
|
|
2956
|
+
"openWorldHint": False,
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
#: Password-like public links are handed out here, so the client is told the tool
|
|
2960
|
+
#: reaches outside the host (``openWorldHint``). It is still not destructive.
|
|
2961
|
+
_ANNOTATIONS_PUBLISH: Dict[str, Any] = {
|
|
2962
|
+
"readOnlyHint": False,
|
|
2963
|
+
"destructiveHint": False,
|
|
2964
|
+
"idempotentHint": False,
|
|
2965
|
+
"openWorldHint": True,
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
_ANNOTATIONS_DESTRUCTIVE: Dict[str, Any] = {
|
|
2969
|
+
"readOnlyHint": False,
|
|
2970
|
+
"destructiveHint": True,
|
|
2971
|
+
"idempotentHint": False,
|
|
2972
|
+
"openWorldHint": False,
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
#: Camera, microphone, location, fingerprint, USB, infrared, SMS, contacts and the
|
|
2976
|
+
#: call log. They delete nothing, so "destructive" is not literally true - but
|
|
2977
|
+
#: ``destructiveHint`` is the only hint clients such as Gemini Spark reliably turn
|
|
2978
|
+
#: into a "confirm this action?" prompt, and these calls observe the physical world
|
|
2979
|
+
#: (or the private messages of the person holding the phone) without the operator
|
|
2980
|
+
#: necessarily watching. Marking them destructive is therefore the honest safety
|
|
2981
|
+
#: choice, and the README documents the trade-off.
|
|
2982
|
+
_ANNOTATIONS_SENSITIVE: Dict[str, Any] = {
|
|
2983
|
+
"readOnlyHint": False,
|
|
2984
|
+
"destructiveHint": True,
|
|
2985
|
+
"idempotentHint": False,
|
|
2986
|
+
"openWorldHint": False,
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
#: The confirmation policy, one entry per advertised tool. Classify every new tool
|
|
2990
|
+
#: here: a tool missing from this table stays unannotated on purpose, which means
|
|
2991
|
+
#: the client falls back to its default "ask first" - the safe answer for a tool
|
|
2992
|
+
#: nobody has judged yet. ``tests/test_tool_annotations.py`` fails on such a tool
|
|
2993
|
+
#: so the decision cannot be forgotten.
|
|
2994
|
+
TOOL_ANNOTATIONS: Dict[str, Dict[str, Any]] = {
|
|
2995
|
+
# --- read-only: looking around never needs a prompt ----------------------
|
|
2996
|
+
"mesh_status": _ANNOTATIONS_READ_ONLY,
|
|
2997
|
+
"system_info": _ANNOTATIONS_READ_ONLY,
|
|
2998
|
+
"system_vitals": _ANNOTATIONS_READ_ONLY,
|
|
2999
|
+
"get_orchestration_skill": _ANNOTATIONS_READ_ONLY,
|
|
3000
|
+
"list_dir": _ANNOTATIONS_READ_ONLY,
|
|
3001
|
+
"read_file": _ANNOTATIONS_READ_ONLY,
|
|
3002
|
+
"grep_search": _ANNOTATIONS_READ_ONLY,
|
|
3003
|
+
"glob_find": _ANNOTATIONS_READ_ONLY,
|
|
3004
|
+
"job_output": _ANNOTATIONS_READ_ONLY,
|
|
3005
|
+
"job_list": _ANNOTATIONS_READ_ONLY,
|
|
3006
|
+
"share_list": _ANNOTATIONS_READ_ONLY,
|
|
3007
|
+
# --- local work: writes and commands, but no install and no delete -------
|
|
3008
|
+
"bash_exec": _ANNOTATIONS_LOCAL_WRITE,
|
|
3009
|
+
"run_job": _ANNOTATIONS_LOCAL_WRITE,
|
|
3010
|
+
# Stopping a background job is not an install and not a delete, and
|
|
3011
|
+
# bash_exec can terminate the same process anyway; a prompt here would only
|
|
3012
|
+
# add noise, so it carries the same profile as the rest of the local work.
|
|
3013
|
+
"job_kill": _ANNOTATIONS_LOCAL_WRITE,
|
|
3014
|
+
"write_file": _ANNOTATIONS_LOCAL_WRITE,
|
|
3015
|
+
"edit_file": _ANNOTATIONS_LOCAL_WRITE,
|
|
3016
|
+
# --- device branch: the node's own phone or laptop -----------------------
|
|
3017
|
+
# Reading the device changes nothing, so it never prompts; the reversible
|
|
3018
|
+
# actions are ordinary local work; what the physical world can observe (camera,
|
|
3019
|
+
# microphone, location) and what is private to a person (messages, contacts)
|
|
3020
|
+
# always asks first.
|
|
3021
|
+
"device_info": _ANNOTATIONS_READ_ONLY,
|
|
3022
|
+
"device_control": _ANNOTATIONS_LOCAL_WRITE,
|
|
3023
|
+
"device_capture": _ANNOTATIONS_SENSITIVE,
|
|
3024
|
+
"device_messages": _ANNOTATIONS_SENSITIVE,
|
|
3025
|
+
# --- publishing something on the internet -------------------------------
|
|
3026
|
+
"share_file": _ANNOTATIONS_PUBLISH,
|
|
3027
|
+
"serve_dir": _ANNOTATIONS_PUBLISH,
|
|
3028
|
+
# --- destructive: install or delete/revoke -> confirm first --------------
|
|
3029
|
+
"mesh_update": _ANNOTATIONS_DESTRUCTIVE,
|
|
3030
|
+
"unshare": _ANNOTATIONS_DESTRUCTIVE,
|
|
3031
|
+
}
|
|
3032
|
+
|
|
1551
3033
|
TOOLS: List[Dict[str, Any]] = [
|
|
1552
3034
|
{
|
|
1553
3035
|
"name": "mesh_status",
|
|
1554
3036
|
"title": "Mesh Status",
|
|
1555
3037
|
"description": ("Confirm this node is reachable. Call this first if you think the host is "
|
|
1556
|
-
"offline; it reports live evidence from the host itself
|
|
3038
|
+
"offline; it reports live evidence from the host itself, plus what kind "
|
|
3039
|
+
"of machine answered (device_class, scenario) and its battery level."),
|
|
1557
3040
|
"inputSchema": _schema({}),
|
|
1558
3041
|
},
|
|
1559
3042
|
{
|
|
1560
3043
|
"name": "system_info",
|
|
1561
3044
|
"title": "System Info",
|
|
1562
3045
|
"description": ("One-call host summary: OS, desktop, user, home, disks, memory, load, "
|
|
1563
|
-
"top processes and the
|
|
3046
|
+
"top processes, the current wallpaper, and the device blocks - "
|
|
3047
|
+
"battery and charging, network and signal, language, time, timezone, "
|
|
3048
|
+
"CPU, RAM, storage, cameras, microphones and sensors. On a phone it "
|
|
3049
|
+
"reads Android through the termux-api commands; on a laptop it reads "
|
|
3050
|
+
"the local system. Every device block carries available/source, and a "
|
|
3051
|
+
"block the platform cannot answer says why instead of showing a zero."),
|
|
1564
3052
|
"inputSchema": _schema({}),
|
|
1565
3053
|
},
|
|
1566
3054
|
{
|
|
1567
3055
|
"name": "system_vitals",
|
|
1568
3056
|
"title": "System Vitals",
|
|
1569
|
-
"description": "Retrieve CPU, RAM and disk metrics of the local host
|
|
3057
|
+
"description": ("Retrieve CPU, RAM and disk metrics of the local host, plus the "
|
|
3058
|
+
"battery block (and thermal sensors where the platform exposes them)."),
|
|
1570
3059
|
"inputSchema": _schema({}),
|
|
1571
3060
|
},
|
|
1572
3061
|
{
|
|
@@ -1585,9 +3074,12 @@ TOOLS: List[Dict[str, Any]] = [
|
|
|
1585
3074
|
"name": "bash_exec",
|
|
1586
3075
|
"title": "Execute Bash Command",
|
|
1587
3076
|
"description": (
|
|
1588
|
-
"Execute a shell command on the local host.
|
|
3077
|
+
"Execute a shell command on the local host. The shell matches the host, not this "
|
|
3078
|
+
"tool's name: run system_info and read command_shell first (bash on Linux/macOS, "
|
|
3079
|
+
"PowerShell or cmd.exe on Windows) and write the command for that shell. "
|
|
3080
|
+
"Output is paginated: when the result is "
|
|
1589
3081
|
"cut, call again with cursor=next_cursor to continue (nothing is dropped). Large "
|
|
1590
|
-
"outputs (
|
|
3082
|
+
"outputs (above 2 MB) are spooled to a file returned in saved_to."
|
|
1591
3083
|
),
|
|
1592
3084
|
"inputSchema": _schema(
|
|
1593
3085
|
{
|
|
@@ -1620,13 +3112,17 @@ TOOLS: List[Dict[str, Any]] = [
|
|
|
1620
3112
|
{
|
|
1621
3113
|
"name": "write_file",
|
|
1622
3114
|
"title": "Write File",
|
|
1623
|
-
"description":
|
|
3115
|
+
"description": (
|
|
3116
|
+
"Atomically create or overwrite a file (tmp file + os.replace). On Windows the "
|
|
3117
|
+
"optional mode is not honoured (no POSIX permission bits) and the result says so."
|
|
3118
|
+
),
|
|
1624
3119
|
"inputSchema": _schema(
|
|
1625
3120
|
{
|
|
1626
3121
|
"path": {"type": "string", "description": "File path to write."},
|
|
1627
3122
|
"content": {"type": "string", "description": "Full file content."},
|
|
1628
3123
|
"create_dirs": {"type": "boolean", "description": "Create parent directories (default true)."},
|
|
1629
|
-
"mode": {"type": "string",
|
|
3124
|
+
"mode": {"type": "string",
|
|
3125
|
+
"description": "Octal file mode, e.g. \"0644\". POSIX only; ignored on Windows."},
|
|
1630
3126
|
},
|
|
1631
3127
|
["path", "content"],
|
|
1632
3128
|
),
|
|
@@ -1682,7 +3178,9 @@ TOOLS: List[Dict[str, Any]] = [
|
|
|
1682
3178
|
{
|
|
1683
3179
|
"name": "run_job",
|
|
1684
3180
|
"title": "Run Background Job",
|
|
1685
|
-
"description": "Start a command in the background; returns a job_id for job_output/job_kill."
|
|
3181
|
+
"description": "Start a command in the background; returns a job_id for job_output/job_kill. "
|
|
3182
|
+
"On an Android/Termux node the job's wake lock is taken automatically so "
|
|
3183
|
+
"Android does not freeze it while the screen is off.",
|
|
1686
3184
|
"inputSchema": _schema(
|
|
1687
3185
|
{
|
|
1688
3186
|
"command": {"type": "string", "description": "Shell command to run in the background."},
|
|
@@ -1725,17 +3223,240 @@ TOOLS: List[Dict[str, Any]] = [
|
|
|
1725
3223
|
"limit": {"type": "integer", "description": "How many jobs to return (1-50, default 20)."},
|
|
1726
3224
|
}),
|
|
1727
3225
|
},
|
|
3226
|
+
{
|
|
3227
|
+
"name": "mesh_update",
|
|
3228
|
+
"title": "Mesh Update (self-update)",
|
|
3229
|
+
"description": (
|
|
3230
|
+
"Report, check for, or install a newer release of this node's own code from "
|
|
3231
|
+
"GitHub. action=status reads local state without network; action=check asks "
|
|
3232
|
+
"GitHub Releases; action=apply downloads the release payload, verifies its "
|
|
3233
|
+
"published SHA-256, installs it with a rollback backup and restarts the agent "
|
|
3234
|
+
"(the tunnel drops for a few seconds; the tool result is still sent first). "
|
|
3235
|
+
"Use status or check first, and apply only when the operator wants this node "
|
|
3236
|
+
"updated now."
|
|
3237
|
+
),
|
|
3238
|
+
"inputSchema": _schema({
|
|
3239
|
+
"action": {"type": "string", "enum": ["status", "check", "apply"],
|
|
3240
|
+
"description": "What to do (default: status)."},
|
|
3241
|
+
"force": {"type": "boolean",
|
|
3242
|
+
"description": "Ignore the check interval and the failed-apply backoff."},
|
|
3243
|
+
"offline": {"type": "boolean",
|
|
3244
|
+
"description": "check: report the cached answer without touching the network."},
|
|
3245
|
+
"restart": {"type": "boolean",
|
|
3246
|
+
"description": "apply: restart the node afterwards (default true)."},
|
|
3247
|
+
}),
|
|
3248
|
+
},
|
|
3249
|
+
{
|
|
3250
|
+
"name": "device_info",
|
|
3251
|
+
"title": "Device Info",
|
|
3252
|
+
"description": (
|
|
3253
|
+
"One-call report for the device this node runs on: battery and charging, "
|
|
3254
|
+
"network interfaces with Wi-Fi and cellular signal strength, language, time and "
|
|
3255
|
+
"timezone, CPU, RAM, storage, cameras, microphones, sensors and what the platform "
|
|
3256
|
+
"can actually reach. On an Android/Termux node it reads the phone through the "
|
|
3257
|
+
"termux-api commands; on a laptop or desktop it reads the local system. Every "
|
|
3258
|
+
"section carries available/source, so 'not exposed here' is never reported as a "
|
|
3259
|
+
"zero - and on a non-phone the phone-only sections explain themselves instead of "
|
|
3260
|
+
"failing. Use device_control, device_capture or device_messages to act."
|
|
3261
|
+
),
|
|
3262
|
+
"inputSchema": _schema({
|
|
3263
|
+
"section": {"type": "string",
|
|
3264
|
+
"enum": ["summary", "all", "device", "battery", "network", "locale",
|
|
3265
|
+
"time", "hardware", "storage", "cameras", "microphones",
|
|
3266
|
+
"sensors", "capabilities"],
|
|
3267
|
+
"description": "Which blocks to return (default: all)."},
|
|
3268
|
+
"sensor": {"type": "string",
|
|
3269
|
+
"description": "Name one sensor to take a single live sample from "
|
|
3270
|
+
"(Android only; needs a name from the sensors block)."},
|
|
3271
|
+
"fresh": {"type": "boolean",
|
|
3272
|
+
"description": "Bypass the short-lived cache (default false)."},
|
|
3273
|
+
"quick": {"type": "boolean",
|
|
3274
|
+
"description": "Skip the slow sections: network, cameras, microphones, "
|
|
3275
|
+
"sensors (default false)."},
|
|
3276
|
+
}),
|
|
3277
|
+
},
|
|
3278
|
+
{
|
|
3279
|
+
"name": "device_control",
|
|
3280
|
+
"title": "Device Control",
|
|
3281
|
+
"description": (
|
|
3282
|
+
"Reversible actions on the node's own device: torch, vibrate, volume, screen "
|
|
3283
|
+
"brightness, text-to-speech, toast, notifications, clipboard, media playback, "
|
|
3284
|
+
"media scan, wake lock, download, opening or sharing a file, a text dialog and "
|
|
3285
|
+
"the wallpaper. Each action is a fixed termux-api command on Android and an "
|
|
3286
|
+
"explained no-op elsewhere; the result names the command and its argv so nothing "
|
|
3287
|
+
"is guessed. Nothing here is destructive, and all of it is refused when the node "
|
|
3288
|
+
"runs with MESH_READ_ONLY=1."
|
|
3289
|
+
),
|
|
3290
|
+
"inputSchema": _schema({
|
|
3291
|
+
"action": {"type": "string",
|
|
3292
|
+
"enum": list(DEVICE_CONTROL_ACTIONS),
|
|
3293
|
+
"description": "Action to perform."},
|
|
3294
|
+
"on": {"type": "boolean", "description": "torch: true lights it, false puts it out."},
|
|
3295
|
+
"value": {"type": "integer",
|
|
3296
|
+
"description": "vibrate: milliseconds; volume: level 0-15; brightness: 0-255."},
|
|
3297
|
+
"stream": {"type": "string", "enum": list(_DEVICE_STREAMS),
|
|
3298
|
+
"description": "volume: which audio stream (default music)."},
|
|
3299
|
+
"text": {"type": "string",
|
|
3300
|
+
"description": "tts_speak/toast/notify/clipboard_set/dialog body; media: "
|
|
3301
|
+
"play|pause|stop|info."},
|
|
3302
|
+
"title": {"type": "string", "description": "notify/dialog: heading text."},
|
|
3303
|
+
"id": {"type": "string",
|
|
3304
|
+
"description": "notify_remove: the id the notification was posted with."},
|
|
3305
|
+
"url": {"type": "string", "description": "download/open/wallpaper: URL to use."},
|
|
3306
|
+
"path": {"type": "string",
|
|
3307
|
+
"description": "open/share/media/download/media_scan/wallpaper: file on the node."},
|
|
3308
|
+
"state": {"type": "string", "enum": ["acquire", "release", "status"],
|
|
3309
|
+
"description": "wakelock: what to do (default status)."},
|
|
3310
|
+
"timeout_sec": {"type": "integer",
|
|
3311
|
+
"description": "dialog: seconds to wait for the person (5-120, default 30)."},
|
|
3312
|
+
}, ["action"]),
|
|
3313
|
+
},
|
|
3314
|
+
{
|
|
3315
|
+
"name": "device_capture",
|
|
3316
|
+
"title": "Device Capture (asks first)",
|
|
3317
|
+
"description": (
|
|
3318
|
+
"Camera, microphone, location, fingerprint, USB and infrared actions. These "
|
|
3319
|
+
"observe the physical world or authenticate the person holding the phone, so the "
|
|
3320
|
+
"client always asks for confirmation first. A photo or a recording is written to "
|
|
3321
|
+
"the node's capture directory and is never uploaded anywhere by itself; "
|
|
3322
|
+
"fingerprint returns only the verdict, never biometric data; USB access requests "
|
|
3323
|
+
"permission and returns the descriptor but deliberately refuses to run a program "
|
|
3324
|
+
"for you; and on Android 11+ the Termux:API app has to be in the foreground for a "
|
|
3325
|
+
"recording. Refused when MESH_READ_ONLY=1."
|
|
3326
|
+
),
|
|
3327
|
+
"inputSchema": _schema({
|
|
3328
|
+
"action": {"type": "string",
|
|
3329
|
+
"enum": list(DEVICE_CAPTURE_ACTIONS),
|
|
3330
|
+
"description": "Capture action to perform."},
|
|
3331
|
+
"camera_id": {"type": "integer",
|
|
3332
|
+
"description": "camera_photo: id from camera_list (default 0)."},
|
|
3333
|
+
"path": {"type": "string",
|
|
3334
|
+
"description": "camera_photo/mic_record_start: output file (default: the "
|
|
3335
|
+
"node's capture directory); usb_access: device path from "
|
|
3336
|
+
"usb_list."},
|
|
3337
|
+
"seconds": {"type": "integer",
|
|
3338
|
+
"description": "mic_record_start: recording length limit in seconds."},
|
|
3339
|
+
"provider": {"type": "string", "enum": ["gps", "network", "passive"],
|
|
3340
|
+
"description": "location: which Android provider to use (default gps)."},
|
|
3341
|
+
"frequency": {"type": "integer",
|
|
3342
|
+
"description": "infrared_transmit: carrier frequency in Hz."},
|
|
3343
|
+
"pattern": {"type": "string",
|
|
3344
|
+
"description": "infrared_transmit: durations in microseconds, e.g. "
|
|
3345
|
+
"1000,2000,1000."},
|
|
3346
|
+
}, ["action"]),
|
|
3347
|
+
},
|
|
3348
|
+
{
|
|
3349
|
+
"name": "device_messages",
|
|
3350
|
+
"title": "Device Messages (asks first)",
|
|
3351
|
+
"description": (
|
|
3352
|
+
"SMS, call log, contacts and placing a call. This is private data about the person "
|
|
3353
|
+
"who owns the phone, so the client asks for confirmation AND the node refuses "
|
|
3354
|
+
"until its operator enables it with MESH_DEVICE_PIM=1. The refusal names the flag "
|
|
3355
|
+
"and the Android permissions to grant, so the answer is an instruction rather than "
|
|
3356
|
+
"a dead end. Sending an SMS and placing a call are also refused under "
|
|
3357
|
+
"MESH_READ_ONLY=1."
|
|
3358
|
+
),
|
|
3359
|
+
"inputSchema": _schema({
|
|
3360
|
+
"action": {"type": "string", "enum": list(DEVICE_MESSAGES_ACTIONS),
|
|
3361
|
+
"description": "Message or PIM action to perform."},
|
|
3362
|
+
"number": {"type": "string", "description": "sms_send/call: recipient number."},
|
|
3363
|
+
"text": {"type": "string", "description": "sms_send: message body."},
|
|
3364
|
+
"limit": {"type": "integer", "description": "How many records (1-50, default 10)."},
|
|
3365
|
+
"offset": {"type": "integer", "description": "Record offset, for paging."},
|
|
3366
|
+
"type": {"type": "string",
|
|
3367
|
+
"description": "sms_list/call_log: filter, e.g. inbox/sent or "
|
|
3368
|
+
"incoming/outgoing/missed."},
|
|
3369
|
+
"query": {"type": "string", "description": "contacts: filter by name or number."},
|
|
3370
|
+
}, ["action"]),
|
|
3371
|
+
},
|
|
3372
|
+
{
|
|
3373
|
+
"name": "share_file",
|
|
3374
|
+
"title": "Share File (public URL)",
|
|
3375
|
+
"description": (
|
|
3376
|
+
"Publish ONE local file on the internet through the Mesh gateway. The file is copied "
|
|
3377
|
+
"into the node's share root and a public HTTPS link is returned on the shared domain: "
|
|
3378
|
+
"https://<shared-domain>/<node>/<name>-<random>/<filename>. Anyone who has the link can "
|
|
3379
|
+
"read the file (the random part of the path is the credential), so treat the link like a "
|
|
3380
|
+
"password. The node must be connected to the Mesh gateway for the link to work. Use "
|
|
3381
|
+
"share_list to see active shares and unshare to revoke one."
|
|
3382
|
+
),
|
|
3383
|
+
"inputSchema": _schema(
|
|
3384
|
+
{
|
|
3385
|
+
"path": {"type": "string", "description": "Path of the local file to publish."},
|
|
3386
|
+
"name": {"type": "string",
|
|
3387
|
+
"description": "Human name in the URL (default: the file name without extension)."},
|
|
3388
|
+
"overwrite": {"type": "boolean",
|
|
3389
|
+
"description": "Replace an existing share with the same name (default false)."},
|
|
3390
|
+
},
|
|
3391
|
+
["path"],
|
|
3392
|
+
),
|
|
3393
|
+
},
|
|
3394
|
+
{
|
|
3395
|
+
"name": "serve_dir",
|
|
3396
|
+
"title": "Serve Directory (public URL)",
|
|
3397
|
+
"description": (
|
|
3398
|
+
"Start a static web server for a local directory and return its public HTTPS link: "
|
|
3399
|
+
"https://<shared-domain>/<node>/<name>-<random>/. The directory is served in place "
|
|
3400
|
+
"(no copy): index.html is used when present, otherwise a directory listing is shown. "
|
|
3401
|
+
"Serving is read-only (GET/HEAD only) and the random path segment is the credential. "
|
|
3402
|
+
"Use unshare to stop the server; the directory itself is never deleted."
|
|
3403
|
+
),
|
|
3404
|
+
"inputSchema": _schema(
|
|
3405
|
+
{
|
|
3406
|
+
"path": {"type": "string", "description": "Local directory to serve."},
|
|
3407
|
+
"name": {"type": "string",
|
|
3408
|
+
"description": "Human name in the URL (default: the directory name)."},
|
|
3409
|
+
},
|
|
3410
|
+
["path"],
|
|
3411
|
+
),
|
|
3412
|
+
},
|
|
3413
|
+
{
|
|
3414
|
+
"name": "share_list",
|
|
3415
|
+
"title": "Share List",
|
|
3416
|
+
"description": "List active public shares with their URLs, roots and whether the local server is running.",
|
|
3417
|
+
"inputSchema": _schema({}),
|
|
3418
|
+
},
|
|
3419
|
+
{
|
|
3420
|
+
"name": "unshare",
|
|
3421
|
+
"title": "Unshare (revoke URL)",
|
|
3422
|
+
"description": (
|
|
3423
|
+
"Stop a public share and revoke its link. Accepts the share name, its slug or the full "
|
|
3424
|
+
"URL. A file share also deletes the copy that was made in the share root; a served "
|
|
3425
|
+
"directory is left untouched on disk."
|
|
3426
|
+
),
|
|
3427
|
+
"inputSchema": _schema(
|
|
3428
|
+
{"name": {"type": "string", "description": "Share name, slug or public URL from share_list."}},
|
|
3429
|
+
["name"],
|
|
3430
|
+
),
|
|
3431
|
+
},
|
|
1728
3432
|
]
|
|
1729
3433
|
|
|
1730
3434
|
|
|
3435
|
+
# Attach the confirmation policy to the advertised specs. Kept out of the literal
|
|
3436
|
+
# above so the policy reads as one table instead of twenty scattered flags.
|
|
3437
|
+
def _attach_annotations(tools: List[Dict[str, Any]]) -> None:
|
|
3438
|
+
for spec in tools:
|
|
3439
|
+
profile = TOOL_ANNOTATIONS.get(spec.get("name"))
|
|
3440
|
+
if profile is not None:
|
|
3441
|
+
spec["annotations"] = dict(profile)
|
|
3442
|
+
|
|
3443
|
+
|
|
3444
|
+
_attach_annotations(TOOLS)
|
|
3445
|
+
|
|
3446
|
+
|
|
1731
3447
|
# ---------------------------------------------------------------------------
|
|
1732
3448
|
# Dispatch
|
|
1733
3449
|
# ---------------------------------------------------------------------------
|
|
1734
3450
|
|
|
1735
3451
|
_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Any]] = {
|
|
1736
3452
|
"mesh_status": _tool_mesh_status,
|
|
3453
|
+
"mesh_update": _tool_mesh_update,
|
|
1737
3454
|
"system_info": _tool_system_info,
|
|
1738
3455
|
"system_vitals": _tool_system_vitals,
|
|
3456
|
+
"device_info": _tool_device_info,
|
|
3457
|
+
"device_control": _tool_device_control,
|
|
3458
|
+
"device_capture": _tool_device_capture,
|
|
3459
|
+
"device_messages": _tool_device_messages,
|
|
1739
3460
|
"get_orchestration_skill": _tool_get_orchestration_skill,
|
|
1740
3461
|
"list_dir": _tool_list_dir,
|
|
1741
3462
|
"bash_exec": _tool_bash_exec,
|
|
@@ -1748,6 +3469,13 @@ _HANDLERS: Dict[str, Callable[[Dict[str, Any]], Any]] = {
|
|
|
1748
3469
|
"job_output": _tool_job_output,
|
|
1749
3470
|
"job_kill": _tool_job_kill,
|
|
1750
3471
|
"job_list": _tool_job_list,
|
|
3472
|
+
"share_file": _tool_share_file,
|
|
3473
|
+
"serve_dir": _tool_serve_dir,
|
|
3474
|
+
"share_list": _tool_share_list,
|
|
3475
|
+
"unshare": _tool_unshare,
|
|
3476
|
+
# Internal gateway relay (never advertised in TOOLS / tools/list). The deployed
|
|
3477
|
+
# gateway reaches a share by calling this tool over the tunnel.
|
|
3478
|
+
"_http_share": _tool_http_share,
|
|
1751
3479
|
}
|
|
1752
3480
|
|
|
1753
3481
|
|