gemini-computer-use 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/mcp_tools.py DELETED
@@ -1,3497 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """core/mcp_tools.py - shared Antigravity Mesh tool implementation.
4
-
5
- This module is the single place that contains tool *logic*. Both consumers
6
- (the asyncio tunnel node ``core/agent.py`` and the standalone HTTP server
7
- ``core/server.py``) are thin transports that only forward to
8
- :func:`call_tool`.
9
-
10
- Design rules (see the project contract):
11
-
12
- * standard library only, Python 3.8+;
13
- * :func:`call_tool` never raises - every failure is returned as
14
- ``{"error": "<human readable>"}``;
15
- * output pagination never throws the middle away: callers advance a ``cursor``;
16
- * large outputs are spooled to a file instead of being dropped;
17
- * every mutating operation is checked against ``read_only`` / ``write_roots``.
18
-
19
- Public interface::
20
-
21
- TOOLS: list[dict]
22
- configure(**kwargs) -> None
23
- call_tool(name: str, args: dict) -> dict
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import fnmatch
29
- import glob as _glob
30
- import hashlib
31
- import json
32
- import locale
33
- import os
34
- import re
35
- import shutil
36
- import signal as _signal
37
- import socket
38
- import subprocess
39
- import tempfile
40
- import threading
41
- import time
42
- import uuid
43
- from datetime import datetime, timezone
44
- from typing import Any, Callable, Dict, List, Optional, Tuple
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
-
53
- # ---------------------------------------------------------------------------
54
- # Optional dependency: the host vitals collector. Kept optional so the module
55
- # stays importable under a bare interpreter.
56
- # ---------------------------------------------------------------------------
57
- try: # pragma: no cover - exercised implicitly by the environment
58
- from core.vitals import get_host_vitals as _core_get_host_vitals # type: ignore
59
- except Exception: # pragma: no cover
60
- _core_get_host_vitals = None # type: ignore
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
-
89
-
90
- # ---------------------------------------------------------------------------
91
- # Platform / command shell
92
- # ---------------------------------------------------------------------------
93
-
94
- _IS_WINDOWS = os.name == "nt"
95
-
96
- #: Hide the console window of every child process on Windows: the node runs
97
- #: hidden, and a flashing window per command would be the only thing the user sees.
98
- _POPEN_FLAGS = 0
99
- if _IS_WINDOWS: # pragma: no cover - Windows only
100
- _POPEN_FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | getattr(
101
- subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
102
-
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()
116
-
117
-
118
- def _git_bash_windows() -> Optional[str]:
119
- """Git for Windows' bash.exe, never System32\\bash.exe.
120
-
121
- On Windows "bash" on PATH is normally the WSL launcher: it runs the command
122
- inside a Linux VM (wrong files, wrong processes) or fails without WSL.
123
- """
124
- roots = [os.environ.get(k) for k in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LOCALAPPDATA")]
125
- candidates = []
126
- for root in filter(None, roots):
127
- candidates.append(os.path.join(root, "Git", "bin", "bash.exe"))
128
- candidates.append(os.path.join(root, "Programs", "Git", "bin", "bash.exe"))
129
- git = shutil.which("git")
130
- if git: # ...\Git\cmd\git.exe -> ...\Git\bin\bash.exe
131
- candidates.append(os.path.join(os.path.dirname(os.path.dirname(git)), "bin", "bash.exe"))
132
- for path in candidates:
133
- if os.path.isfile(path):
134
- return path
135
- return None
136
-
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
-
171
- def command_shell() -> Tuple[str, List[str]]:
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:
187
- if _IS_WINDOWS:
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()
193
- else:
194
- _SHELL_CACHE = default_windows_shell()
195
- else:
196
- bash = shutil.which("bash")
197
- _SHELL_CACHE = ("bash", [bash, "-c"]) if bash else ("sh", ["/bin/sh", "-c"])
198
- _SHELL_CACHE_KEY = override
199
- return _SHELL_CACHE
200
-
201
-
202
- def _shell_argv(command: str) -> List[str]:
203
- return command_shell()[1] + [command]
204
-
205
-
206
- # ---------------------------------------------------------------------------
207
- # Configuration / module state
208
- # ---------------------------------------------------------------------------
209
-
210
- _DEFAULT_MAX_OUTPUT_CHARS = 50000
211
- _DEFAULT_JOBS_DIR = os.path.join("~", ".cache", "antigravity-mesh", "jobs")
212
-
213
- #: bash_exec never blocks a transport thread for longer than this.
214
- MAX_TIMEOUT_SEC = 120
215
- #: job_output caps its wait so it stays well inside the 28s gateway limit.
216
- MAX_WAIT_MS = 20000
217
- #: run_job refuses to start more than this many concurrent jobs.
218
- MAX_RUNNING_JOBS = 32
219
- #: Per-job output written to disk before a marker is appended and growth stops.
220
- JOB_OUTPUT_CAP = 64 * 1024 * 1024
221
- #: bash_exec spools its complete output to disk above this size.
222
- SPOOL_THRESHOLD = 2 * 1024 * 1024
223
- #: Hard ceiling for glob_find result sets.
224
- GLOB_MAX_RESULTS = 10000
225
-
226
- _SKIP_DIRS = frozenset((".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv"))
227
-
228
- _WORKSPACE = os.path.realpath(os.getcwd())
229
- _READ_ONLY = False
230
- _WRITE_ROOTS: Optional[List[str]] = None
231
- _JOBS_DIR = os.path.realpath(os.path.expanduser(_DEFAULT_JOBS_DIR))
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
252
-
253
- _META_LOCK = threading.RLock()
254
- _JOBS: Dict[str, Dict[str, Any]] = {}
255
-
256
-
257
- def _split_roots(value: Any) -> Optional[List[str]]:
258
- """Normalise ``write_roots`` (list/tuple/pathsep string) into real paths."""
259
- if value is None:
260
- return None
261
- if isinstance(value, str):
262
- parts = [p for p in value.split(os.pathsep) if p.strip()]
263
- if len(parts) == 1 and os.pathsep not in value:
264
- # A single root with no separator: still accept it verbatim.
265
- parts = [value]
266
- elif isinstance(value, (list, tuple, set)):
267
- parts = [str(p) for p in value if str(p).strip()]
268
- else:
269
- parts = [str(value)]
270
- roots: List[str] = []
271
- for part in parts:
272
- if not str(part).strip():
273
- continue
274
- roots.append(os.path.realpath(os.path.expanduser(str(part).strip())))
275
- return roots or None
276
-
277
-
278
- def _parse_bool(value: Any) -> bool:
279
- if isinstance(value, bool):
280
- return value
281
- if value is None:
282
- return False
283
- return str(value).strip().lower() in ("1", "true", "yes", "on", "y", "t")
284
-
285
-
286
- def configure(**kwargs: Any) -> None:
287
- """(Re)configure the module. All keys are optional.
288
-
289
- Recognised keys: ``workspace``, ``read_only``, ``allow_write``,
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).
299
- """
300
- global _WORKSPACE, _READ_ONLY, _WRITE_ROOTS, _JOBS_DIR, _MAX_OUTPUT_CHARS
301
- global _PUBLIC_URL, _DEVICE_ENABLED, _DEVICE_ACTIONS, _DEVICE_PIM, _DEVICE_QUICK
302
-
303
- if kwargs.get("workspace"):
304
- _WORKSPACE = os.path.realpath(os.path.expanduser(str(kwargs["workspace"])))
305
- if "read_only" in kwargs and kwargs["read_only"] is not None:
306
- _READ_ONLY = _parse_bool(kwargs["read_only"])
307
- elif "allow_write" in kwargs and kwargs["allow_write"] is not None:
308
- _READ_ONLY = not _parse_bool(kwargs["allow_write"])
309
- if "write_roots" in kwargs:
310
- _WRITE_ROOTS = _split_roots(kwargs["write_roots"])
311
- if kwargs.get("jobs_dir"):
312
- _JOBS_DIR = os.path.realpath(os.path.expanduser(str(kwargs["jobs_dir"])))
313
- if kwargs.get("max_output_chars") is not None:
314
- try:
315
- value = int(kwargs["max_output_chars"])
316
- if value > 0:
317
- _MAX_OUTPUT_CHARS = value
318
- except (TypeError, ValueError):
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()
386
-
387
-
388
- # ---------------------------------------------------------------------------
389
- # Small helpers
390
- # ---------------------------------------------------------------------------
391
-
392
- def _now_iso() -> str:
393
- return datetime.now(timezone.utc).isoformat()
394
-
395
-
396
- def _sha256_hex(data: bytes) -> str:
397
- return hashlib.sha256(data).hexdigest()
398
-
399
-
400
- def _resolve(path: Any) -> str:
401
- """expanduser -> relative to workspace -> realpath."""
402
- raw = "" if path is None else str(path)
403
- raw = raw.strip()
404
- if not raw:
405
- raw = _WORKSPACE
406
- raw = os.path.expanduser(raw)
407
- if not os.path.isabs(raw):
408
- raw = os.path.join(_WORKSPACE, raw)
409
- return os.path.realpath(raw)
410
-
411
-
412
- def _within_write_roots(target: str) -> bool:
413
- if _WRITE_ROOTS is None:
414
- return True
415
- candidate = os.path.realpath(target)
416
- for root in _WRITE_ROOTS:
417
- root = os.path.realpath(root)
418
- if candidate == root:
419
- return True
420
- if candidate.startswith(root.rstrip(os.sep) + os.sep):
421
- return True
422
- return False
423
-
424
-
425
- def _write_guard(target: str) -> Optional[str]:
426
- """Return an error string when writing to *target* is not permitted."""
427
- if _READ_ONLY:
428
- return "server is in read-only mode"
429
- if not _within_write_roots(target):
430
- return "path is outside allowed write roots: %s" % target
431
- return None
432
-
433
-
434
- def _clamp_int(value: Any, low: int, high: int, default: int) -> int:
435
- try:
436
- result = int(value)
437
- except (TypeError, ValueError):
438
- return default
439
- if result < low:
440
- return low
441
- if result > high:
442
- return high
443
- return result
444
-
445
-
446
- def _max_chars(value: Any) -> int:
447
- if value is None:
448
- return _MAX_OUTPUT_CHARS
449
- try:
450
- result = int(value)
451
- except (TypeError, ValueError):
452
- return _MAX_OUTPUT_CHARS
453
- if result < 1:
454
- return 1
455
- if result > 50 * 1024 * 1024:
456
- return 50 * 1024 * 1024
457
- return result
458
-
459
-
460
- def _safe_int(value: Any, default: int = 0) -> int:
461
- try:
462
- return int(value)
463
- except (TypeError, ValueError):
464
- return default
465
-
466
-
467
- def _sanitize_output(text: str) -> str:
468
- """Strip ANSI escapes and non-printable control characters."""
469
- if not text:
470
- return ""
471
- text = re.sub(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", "", text)
472
- return "".join(
473
- ch for ch in text if ch in ("\n", "\r", "\t") or (ord(ch) >= 32 and ord(ch) != 127)
474
- )
475
-
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
-
585
- def _paginate(full: str, cursor: Any, max_chars: int) -> Tuple[str, bool, Optional[int]]:
586
- """Return ``(chunk, truncated, next_cursor)`` for a character offset cursor."""
587
- offset = max(0, _safe_int(cursor, 0))
588
- total = len(full)
589
- if offset >= total:
590
- return "", False, None
591
- end = offset + max_chars
592
- chunk = full[offset:end]
593
- if end < total:
594
- return chunk, True, end
595
- return chunk, False, None
596
-
597
-
598
- def _atomic_write_bytes(target: str, data: bytes, mode: Optional[int] = None) -> None:
599
- """Write *data* to *target* atomically (tmp file in the same dir + replace).
600
-
601
- ``mkstemp`` creates the temporary file with mode 0600, so the mode has to be
602
- set explicitly or an overwrite would silently strip a file's permissions
603
- (e.g. make a launcher script non-executable). Precedence:
604
- explicit ``mode`` > the mode of the file being replaced > 0644 for new files.
605
- """
606
- parent = os.path.dirname(target) or "."
607
- existing_mode = None
608
- if mode is None:
609
- try:
610
- existing_mode = os.stat(target).st_mode & 0o7777
611
- except OSError:
612
- existing_mode = None
613
- fd, tmp = tempfile.mkstemp(prefix=".mcp-tmp-", dir=parent)
614
- try:
615
- with os.fdopen(fd, "wb") as handle:
616
- handle.write(data)
617
- handle.flush()
618
- os.fsync(handle.fileno())
619
- if mode is not None:
620
- os.chmod(tmp, mode)
621
- elif existing_mode is not None:
622
- os.chmod(tmp, existing_mode)
623
- else:
624
- os.chmod(tmp, 0o644)
625
- os.replace(tmp, target)
626
- except Exception:
627
- try:
628
- os.unlink(tmp)
629
- except OSError:
630
- pass
631
- raise
632
-
633
-
634
- def _parse_mode(mode: Any) -> Optional[int]:
635
- if mode is None:
636
- return None
637
- if isinstance(mode, bool):
638
- raise ValueError("invalid mode")
639
- if isinstance(mode, int):
640
- return mode
641
- text = str(mode).strip()
642
- if not text:
643
- return None
644
- return int(text, 8)
645
-
646
-
647
- def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
648
- data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
649
- _atomic_write_bytes(path, data)
650
-
651
-
652
- def _read_json(path: str) -> Optional[Dict[str, Any]]:
653
- try:
654
- with open(path, "r", encoding="utf-8", errors="replace") as handle:
655
- value = json.load(handle)
656
- return value if isinstance(value, dict) else None
657
- except Exception:
658
- return None
659
-
660
-
661
- def _fallback_host_vitals() -> Dict[str, Any]:
662
- """Stdlib-only vitals collector used when core.vitals is unavailable."""
663
- import platform
664
-
665
- system = platform.system()
666
- load = {"1m": 0.0, "5m": 0.0, "15m": 0.0}
667
- try:
668
- if hasattr(os, "getloadavg"):
669
- la = os.getloadavg()
670
- load = {"1m": round(la[0], 2), "5m": round(la[1], 2), "15m": round(la[2], 2)}
671
- except Exception:
672
- pass
673
-
674
- total_mb = 0.0
675
- free_mb = 0.0
676
- try:
677
- with open("/proc/meminfo", "r") as handle:
678
- for line in handle:
679
- if line.startswith("MemTotal:"):
680
- total_mb = round(int(line.split()[1]) / 1024.0, 1)
681
- elif line.startswith("MemAvailable:"):
682
- free_mb = round(int(line.split()[1]) / 1024.0, 1)
683
- except Exception:
684
- pass
685
-
686
- used_mb = max(0.0, round(total_mb - free_mb, 1))
687
- used_pct = round((used_mb / total_mb) * 100.0, 1) if total_mb > 0 else 0.0
688
- try:
689
- du = shutil.disk_usage("C:\\" if system == "Windows" else "/")
690
- disk = {
691
- "total_gb": round(du.total / (1024 ** 3), 2),
692
- "used_gb": round(du.used / (1024 ** 3), 2),
693
- "free_gb": round(du.free / (1024 ** 3), 2),
694
- "used_pct": round((du.used / du.total) * 100.0, 1) if du.total > 0 else 0.0,
695
- }
696
- except Exception:
697
- disk = {"total_gb": 0.0, "used_gb": 0.0, "free_gb": 0.0, "used_pct": 0.0}
698
-
699
- return {
700
- "hostname": socket.gethostname(),
701
- "os": system,
702
- "cpu_load": load,
703
- "ram": {"total_mb": total_mb, "used_mb": used_mb, "free_mb": free_mb, "used_pct": used_pct},
704
- "disk": disk,
705
- }
706
-
707
-
708
- def _skill_path() -> str:
709
- return os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "skills", "orchestrator.md")
710
-
711
-
712
- def _load_orchestration_skill() -> str:
713
- try:
714
- with open(_skill_path(), "r", encoding="utf-8") as handle:
715
- content = handle.read()
716
- if content.strip():
717
- return content
718
- except Exception:
719
- pass
720
- return (
721
- "# Antigravity Mesh Orchestrator Skill (Standalone)\n"
722
- "Mode: Local Standalone Server (antigravity_mesh).\n"
723
- "All commands execute directly on this host through the Antigravity Mesh MCP tools.\n"
724
- "Rules:\n"
725
- "1. Never ask the user to run commands manually; use bash_exec.\n"
726
- "2. Verify exit codes and stdout/stderr before continuing.\n"
727
- "3. On non-zero exit code, halt and report the error details.\n"
728
- )
729
-
730
-
731
- # ---------------------------------------------------------------------------
732
- # Output spooling
733
- # ---------------------------------------------------------------------------
734
-
735
- def _outputs_dir() -> str:
736
- path = os.path.join(_JOBS_DIR, "outputs")
737
- os.makedirs(path, exist_ok=True)
738
- return path
739
-
740
-
741
- def _spool_output(text: str) -> Optional[str]:
742
- """Persist a large text blob and return its absolute path (best effort)."""
743
- try:
744
- path = os.path.join(_outputs_dir(), "%s.out" % uuid.uuid4().hex)
745
- _atomic_write_bytes(path, text.encode("utf-8", "replace"))
746
- return path
747
- except Exception:
748
- return None
749
-
750
-
751
- # ---------------------------------------------------------------------------
752
- # Tool 1-3: legacy tools (kept as-is for compatibility)
753
- # ---------------------------------------------------------------------------
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
-
777
- def _tool_mesh_status(args: Dict[str, Any]) -> Dict[str, Any]:
778
- """Report that this node is reachable, with hard evidence.
779
-
780
- The answer exists because a model may otherwise claim the host is offline or
781
- invent a reason. Every field here was just measured on the node: if this text
782
- arrives at all, the agent and the tunnel are working by definition.
783
- """
784
- import datetime
785
- import socket as _socket
786
-
787
- now = datetime.datetime.now().astimezone()
788
- uptime_s = host_uptime_seconds()
789
- try:
790
- hostname = _socket.gethostname()
791
- except Exception:
792
- hostname = "unknown"
793
-
794
- # The pid and the process start prove the agent is the one answering.
795
- agent_pid = os.getpid()
796
- payload = {
797
- "status": "online",
798
- "reachable": True,
799
- "message": ("This node IS reachable: the request that produced this answer was executed "
800
- "on the host by the mesh agent. If a previous attempt reported the node as "
801
- "offline, that report was wrong - retry the operation instead of concluding "
802
- "the service is down."),
803
- "hostname": hostname,
804
- "agent_pid": agent_pid,
805
- "checked_at": now.isoformat(timespec="seconds"),
806
- "node_utc_offset": now.strftime("%z"),
807
- "workspace": _WORKSPACE,
808
- "pid_namespace": os.uname().nodename if hasattr(os, "uname") else hostname,
809
- }
810
- if uptime_s is not None:
811
- payload["host_uptime_seconds"] = int(uptime_s)
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)}
844
- return payload
845
-
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
-
1112
- def _tool_system_info(args: Dict[str, Any]) -> Dict[str, Any]:
1113
- """A one-call summary of the host.
1114
-
1115
- Exploratory agents spend several tool calls discovering the same basics (OS,
1116
- desktop, home, disks). Gemini's client allows only a handful of calls per
1117
- turn, so this answers them in one round trip.
1118
- """
1119
- import platform
1120
- import socket as _socket
1121
- import subprocess as _sp
1122
-
1123
- system = platform.system()
1124
-
1125
- def run_argv(argv: List[str], timeout: float = 8.0) -> str:
1126
- try:
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()
1133
- except Exception:
1134
- return ""
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
-
1144
- info: Dict[str, Any] = {
1145
- "hostname": _socket.gethostname(),
1146
- "os": platform.platform(),
1147
- "kernel": platform.release(),
1148
- "python": platform.python_version(),
1149
- "user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
1150
- "home": os.path.expanduser("~"),
1151
- "cwd": os.getcwd(),
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.
1156
- "command_shell": command_shell()[0],
1157
- }
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>").
1163
- try:
1164
- edition = platform.win32_edition() # 3.8+, Windows only
1165
- except Exception:
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("'\"")
1210
- if wallpaper:
1211
- wallpaper = largest_wallpaper_image(wallpaper)
1212
- info["wallpaper"] = wallpaper
1213
- info["wallpaper_exists"] = os.path.isfile(wallpaper)
1214
- return info
1215
-
1216
-
1217
- def _tool_system_vitals(args: Dict[str, Any]) -> Any:
1218
- vitals = None
1219
- try:
1220
- if _core_get_host_vitals is not None:
1221
- vitals = _core_get_host_vitals()
1222
- except Exception:
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
1761
-
1762
-
1763
- def _tool_get_orchestration_skill(args: Dict[str, Any]) -> Any:
1764
- return _load_orchestration_skill()
1765
-
1766
-
1767
- def _tool_list_dir(args: Dict[str, Any]) -> Dict[str, Any]:
1768
- try:
1769
- raw = args.get("path", "") or ""
1770
- target = _resolve(raw)
1771
- if not os.path.exists(target):
1772
- return {
1773
- "exit_code": 1,
1774
- "stdout": "",
1775
- "stderr": ("[TOOL ERROR] Path not found: %s -- this path does NOT exist. "
1776
- "Never report it as real; check the parent directory first." % target),
1777
- }
1778
- if os.path.isfile(target):
1779
- return _tool_read_file({"path": target})
1780
-
1781
- entries = []
1782
- for entry in sorted(os.scandir(target), key=lambda x: (not x.is_dir(), x.name.lower())):
1783
- suffix = "/" if entry.is_dir() else ""
1784
- size = entry.stat().st_size if entry.is_file() else 0
1785
- entries.append(
1786
- "%s%s" % (entry.name, suffix)
1787
- + (" (%s bytes)" % size if entry.is_file() else "")
1788
- )
1789
- listing = "\n".join(entries) if entries else "(empty directory)"
1790
- return {
1791
- "exit_code": 0,
1792
- # [VERIFIED BY TOOL] tells the model this listing came from the real
1793
- # filesystem in this call: only such data may be reported as fact.
1794
- "stdout": "[VERIFIED BY TOOL] Directory: %s (%d items):\n%s" % (target, len(entries), listing),
1795
- "stderr": "",
1796
- }
1797
- except Exception as exc:
1798
- return {"error": "list_dir failed: %s" % exc}
1799
-
1800
-
1801
- # ---------------------------------------------------------------------------
1802
- # Tool 4: bash_exec
1803
- # ---------------------------------------------------------------------------
1804
-
1805
- def _run_bash(command: str, timeout: int) -> Tuple[str, str, int, float]:
1806
- started = time.time()
1807
- try:
1808
- # Bytes, not text: the child's encoding is discovered with
1809
- # _decode_output() instead of assumed from the locale (see above).
1810
- proc = subprocess.run(
1811
- _shell_argv(command),
1812
- cwd=_WORKSPACE if os.path.isdir(_WORKSPACE) else None,
1813
- stdin=subprocess.DEVNULL,
1814
- capture_output=True,
1815
- timeout=timeout,
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),
1824
- )
1825
- except subprocess.TimeoutExpired as exc:
1826
- out = _decode_output(exc.stdout)
1827
- err = _decode_output(exc.stderr)
1828
- err = (err + "\n" if err else "") + (
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())
1831
- )
1832
- return out, err, 124, round(time.time() - started, 3)
1833
- except Exception as exc:
1834
- return "", str(exc), 1, round(time.time() - started, 3)
1835
-
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
-
1844
- def _tool_bash_exec(args: Dict[str, Any]) -> Dict[str, Any]:
1845
- command = args.get("command")
1846
- if not isinstance(command, str) or not command.strip():
1847
- return {"error": "command is required and must be a non-empty string"}
1848
- timeout = _clamp_int(args.get("timeout_sec"), 1, MAX_TIMEOUT_SEC, 25)
1849
- max_chars = _max_chars(args.get("max_chars"))
1850
- cursor = max(0, _safe_int(args.get("cursor"), 0))
1851
-
1852
- stdout_raw, stderr_raw, exit_code, duration = _run_bash(command, timeout)
1853
- out = _sanitize_output(stdout_raw)
1854
- err = _sanitize_output(stderr_raw)
1855
-
1856
- full = out + err
1857
- saved_to: Optional[str] = None
1858
- if len(full.encode("utf-8", "replace")) > SPOOL_THRESHOLD:
1859
- saved_to = _spool_output(full)
1860
-
1861
- out_len = len(out)
1862
- offset = min(cursor, len(full))
1863
- end = offset + max_chars
1864
- if offset < out_len:
1865
- stdout_part = full[offset:min(end, out_len)]
1866
- else:
1867
- stdout_part = ""
1868
- if end > out_len:
1869
- stderr_part = full[max(offset, out_len):end]
1870
- else:
1871
- stderr_part = ""
1872
- next_cursor = end if end < len(full) else None
1873
-
1874
- return {
1875
- "stdout": stdout_part,
1876
- "stderr": stderr_part,
1877
- "exit_code": exit_code,
1878
- "duration": duration,
1879
- "truncated": next_cursor is not None,
1880
- "next_cursor": next_cursor,
1881
- "saved_to": saved_to,
1882
- }
1883
-
1884
-
1885
- # ---------------------------------------------------------------------------
1886
- # Tool 5: read_file
1887
- # ---------------------------------------------------------------------------
1888
-
1889
- def _tool_read_file(args: Dict[str, Any]) -> Dict[str, Any]:
1890
- try:
1891
- path = args.get("path")
1892
- if not isinstance(path, str) or not path.strip():
1893
- return {"error": "path is required"}
1894
- target = _resolve(path)
1895
- if not os.path.exists(target):
1896
- return {"error": "[TOOL ERROR] File not found: %s -- does not exist, do not report it as real." % target}
1897
- if os.path.isdir(target):
1898
- return _tool_list_dir({"path": target})
1899
-
1900
- with open(target, "r", encoding="utf-8", errors="replace") as handle:
1901
- lines = handle.readlines()
1902
-
1903
- start_line = max(1, _safe_int(args.get("start_line"), 1) or 1)
1904
- end_line = args.get("end_line")
1905
- end = min(len(lines), _safe_int(end_line, len(lines))) if end_line else len(lines)
1906
- selected = lines[start_line - 1:end]
1907
- width = max(4, len(str(end))) # keep the column stable for files >= 10000 lines
1908
- content = "".join(
1909
- "%*d | %s" % (width, idx + start_line, line) for idx, line in enumerate(selected)
1910
- )
1911
- header = "[VERIFIED BY TOOL] File: %s (lines %d-%d of %d total)\n%s\n" % (
1912
- target,
1913
- start_line,
1914
- end,
1915
- len(lines),
1916
- "-" * 60,
1917
- )
1918
- full = _sanitize_output(header + content)
1919
-
1920
- max_chars = _max_chars(args.get("max_chars"))
1921
- cursor = max(0, _safe_int(args.get("cursor"), 0))
1922
- chunk, truncated, next_cursor = _paginate(full, cursor, max_chars)
1923
- # No marker is appended inside the page: the text must stay byte-exact so
1924
- # a client can stitch pages using next_cursor. Callers surface that value.
1925
-
1926
- return {
1927
- "stdout": chunk,
1928
- "stderr": "",
1929
- "exit_code": 0,
1930
- "truncated": truncated,
1931
- "next_cursor": next_cursor,
1932
- }
1933
- except Exception as exc:
1934
- return {"error": "read_file failed: %s" % exc}
1935
-
1936
-
1937
- # ---------------------------------------------------------------------------
1938
- # Tool 6: write_file
1939
- # ---------------------------------------------------------------------------
1940
-
1941
- def _tool_write_file(args: Dict[str, Any]) -> Dict[str, Any]:
1942
- try:
1943
- path = args.get("path")
1944
- if not isinstance(path, str) or not path.strip():
1945
- return {"error": "path is required"}
1946
- if "content" not in args or args.get("content") is None:
1947
- return {"error": "content is required"}
1948
- content = args.get("content")
1949
- if not isinstance(content, str):
1950
- content = str(content)
1951
-
1952
- target = _resolve(path)
1953
- guard = _write_guard(target)
1954
- if guard:
1955
- return {"error": guard}
1956
-
1957
- create_dirs = args.get("create_dirs", True)
1958
- parent = os.path.dirname(target) or "."
1959
- if create_dirs and parent and not os.path.isdir(parent):
1960
- os.makedirs(parent, exist_ok=True)
1961
- if not os.path.isdir(parent):
1962
- return {"error": "parent directory does not exist: %s" % parent}
1963
-
1964
- try:
1965
- mode = _parse_mode(args.get("mode"))
1966
- except Exception:
1967
- return {"error": "invalid mode: %r" % (args.get("mode"),)}
1968
-
1969
- data = content.encode("utf-8", "replace")
1970
- _atomic_write_bytes(target, data, mode=mode)
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
1999
- except Exception as exc:
2000
- return {"error": "write_file failed: %s" % exc}
2001
-
2002
-
2003
- # ---------------------------------------------------------------------------
2004
- # Tool 7: edit_file
2005
- # ---------------------------------------------------------------------------
2006
-
2007
- def _tool_edit_file(args: Dict[str, Any]) -> Dict[str, Any]:
2008
- try:
2009
- path = args.get("path")
2010
- if not isinstance(path, str) or not path.strip():
2011
- return {"error": "path is required"}
2012
- old_string = args.get("old_string")
2013
- if not isinstance(old_string, str) or old_string == "":
2014
- return {"error": "old_string is required and must be a non-empty string"}
2015
- new_string = args.get("new_string")
2016
- if not isinstance(new_string, str):
2017
- return {"error": "new_string is required and must be a string"}
2018
-
2019
- target = _resolve(path)
2020
- guard = _write_guard(target)
2021
- if guard:
2022
- return {"error": guard}
2023
- if not os.path.isfile(target):
2024
- return {"error": "[TOOL ERROR] File not found: %s -- does not exist, do not report it as real." % target}
2025
-
2026
- with open(target, "rb") as handle:
2027
- raw = handle.read()
2028
- current_sha = _sha256_hex(raw)
2029
-
2030
- expected = args.get("expected_sha256")
2031
- if expected:
2032
- if str(expected).strip().lower() != current_sha:
2033
- return {"error": "file changed since read (sha256 mismatch)"}
2034
-
2035
- # Byte-level replacement: decoding with errors="replace" would silently
2036
- # corrupt non-UTF-8 files (0xff -> U+FFFD). Matching the raw bytes keeps
2037
- # the edit surgical for any encoding while old/new stay valid UTF-8.
2038
- try:
2039
- old_bytes = old_string.encode("utf-8")
2040
- new_bytes = new_string.encode("utf-8")
2041
- except UnicodeEncodeError as exc:
2042
- return {"error": "old_string/new_string must be valid UTF-8 text: %s" % exc}
2043
- count = raw.count(old_bytes)
2044
- if count == 0:
2045
- return {"error": "old_string not found"}
2046
- replace_all = _parse_bool(args.get("replace_all"))
2047
- if count > 1 and not replace_all:
2048
- return {"error": "old_string is not unique (%d matches)" % count}
2049
-
2050
- replacements = count if replace_all else 1
2051
- if replace_all:
2052
- data = raw.replace(old_bytes, new_bytes)
2053
- else:
2054
- data = raw.replace(old_bytes, new_bytes, 1)
2055
-
2056
- _atomic_write_bytes(target, data)
2057
- return {
2058
- "ok": True,
2059
- "path": target,
2060
- "replacements": replacements,
2061
- "sha256": _sha256_hex(data),
2062
- }
2063
- except Exception as exc:
2064
- return {"error": "edit_file failed: %s" % exc}
2065
-
2066
-
2067
- # ---------------------------------------------------------------------------
2068
- # Tool 8: grep_search
2069
- # ---------------------------------------------------------------------------
2070
-
2071
- def _is_probably_binary(path: str) -> bool:
2072
- try:
2073
- with open(path, "rb") as handle:
2074
- return b"\x00" in handle.read(4096)
2075
- except Exception:
2076
- return True
2077
-
2078
-
2079
- def _iter_files(base: str):
2080
- if os.path.isfile(base):
2081
- yield base
2082
- return
2083
- for root, dirs, files in os.walk(base):
2084
- dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
2085
- for name in files:
2086
- yield os.path.join(root, name)
2087
-
2088
-
2089
- def _tool_grep_search(args: Dict[str, Any]) -> Dict[str, Any]:
2090
- try:
2091
- pattern = args.get("pattern")
2092
- if not isinstance(pattern, str) or pattern == "":
2093
- return {"error": "pattern is required and must be a non-empty string"}
2094
- base = _resolve(args.get("path") or ".")
2095
- if not os.path.exists(base):
2096
- return {"error": "Path not found: %s" % base}
2097
-
2098
- limit = _clamp_int(args.get("limit"), 1, 1000, 200)
2099
- ignore_case = _parse_bool(args.get("ignore_case"))
2100
- fixed = _parse_bool(args.get("fixed"))
2101
- try:
2102
- context = max(0, min(20, _safe_int(args.get("context"), 0)))
2103
- except Exception:
2104
- context = 0
2105
- glob_filter = args.get("glob")
2106
- if glob_filter is not None and not isinstance(glob_filter, str):
2107
- return {"error": "glob must be a string"}
2108
-
2109
- flags = re.IGNORECASE if ignore_case else 0
2110
- if fixed:
2111
- needle = pattern.lower() if ignore_case else pattern
2112
- else:
2113
- try:
2114
- regex = re.compile(pattern, flags)
2115
- except re.error as exc:
2116
- return {"error": "invalid regular expression: %s" % exc}
2117
-
2118
- matches: List[Dict[str, Any]] = []
2119
- truncated = False
2120
- for file_path in _iter_files(base):
2121
- if glob_filter and not fnmatch.fnmatch(os.path.basename(file_path), glob_filter):
2122
- continue
2123
- if _is_probably_binary(file_path):
2124
- continue
2125
- try:
2126
- with open(file_path, "r", encoding="utf-8", errors="replace") as handle:
2127
- lines = handle.read().splitlines()
2128
- except Exception:
2129
- continue
2130
-
2131
- hit_lines = []
2132
- for idx, line in enumerate(lines):
2133
- found = (needle in (line.lower() if ignore_case else line)) if fixed else bool(regex.search(line))
2134
- if found:
2135
- hit_lines.append(idx)
2136
- if not hit_lines:
2137
- continue
2138
-
2139
- emitted = set()
2140
- for idx in hit_lines:
2141
- first = max(0, idx - context)
2142
- last = min(len(lines) - 1, idx + context)
2143
- for lineno in range(first, last + 1):
2144
- if lineno in emitted:
2145
- continue
2146
- if len(matches) >= limit:
2147
- truncated = True
2148
- break
2149
- emitted.add(lineno)
2150
- matches.append(
2151
- {"path": os.path.realpath(file_path), "line": lineno + 1, "text": lines[lineno]}
2152
- )
2153
- if truncated:
2154
- break
2155
- if truncated:
2156
- break
2157
-
2158
- return {"matches": matches, "count": len(matches), "truncated": truncated,
2159
- "note": "[VERIFIED BY TOOL] matches come from the real filesystem"}
2160
- except Exception as exc:
2161
- return {"error": "grep_search failed: %s" % exc}
2162
-
2163
-
2164
- # ---------------------------------------------------------------------------
2165
- # Tool 9: glob_find
2166
- # ---------------------------------------------------------------------------
2167
-
2168
- def _skip_path(path: str) -> bool:
2169
- parts = path.split(os.sep)
2170
- return any(part in _SKIP_DIRS for part in parts)
2171
-
2172
-
2173
- def _tool_glob_find(args: Dict[str, Any]) -> Dict[str, Any]:
2174
- try:
2175
- pattern = args.get("pattern")
2176
- if not isinstance(pattern, str) or not pattern.strip():
2177
- return {"error": "pattern is required and must be a non-empty string"}
2178
- base = _resolve(args.get("path") or ".")
2179
- if not os.path.exists(base):
2180
- return {"error": "Path not found: %s" % base}
2181
-
2182
- found: List[str] = []
2183
- if "/" in pattern or "**" in pattern:
2184
- # Patterns containing a path separator must be matched against the
2185
- # path relative to `base` (sub/*.py, */b.py, sub/b.py, **/b.py);
2186
- # matching a bare basename against them always returned nothing.
2187
- try:
2188
- found = _glob.glob(os.path.join(_glob.escape(base), pattern), recursive=True)
2189
- except Exception as exc:
2190
- return {"error": "glob_find failed: %s" % exc}
2191
- found = [p for p in found if os.path.isfile(p) and not _skip_path(p)]
2192
- else:
2193
- for root, dirs, files in os.walk(base):
2194
- dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
2195
- for name in files:
2196
- if fnmatch.fnmatch(name, pattern):
2197
- found.append(os.path.join(root, name))
2198
-
2199
- # Deduplicate: glob.glob itself returns duplicates for patterns such as
2200
- # "**/**/*.py", which would otherwise be reported several times.
2201
- files = sorted({os.path.realpath(p) for p in found})
2202
- truncated = len(files) > GLOB_MAX_RESULTS
2203
- if truncated:
2204
- files = files[:GLOB_MAX_RESULTS]
2205
- return {"files": files, "count": len(files), "truncated": truncated,
2206
- "note": "[VERIFIED BY TOOL] paths come from the real filesystem"}
2207
- except Exception as exc:
2208
- return {"error": "glob_find failed: %s" % exc}
2209
-
2210
-
2211
- # ---------------------------------------------------------------------------
2212
- # Tools 10-13: background jobs
2213
- # ---------------------------------------------------------------------------
2214
-
2215
- class _OutputBudget(object):
2216
- """Shared 64 MB write budget for one job's stdout+stderr streams."""
2217
-
2218
- def __init__(self, cap: int = JOB_OUTPUT_CAP) -> None:
2219
- self.cap = cap
2220
- self.used = 0
2221
- self.notified = False
2222
- self.lock = threading.Lock()
2223
-
2224
- def write(self, handle, chunk: bytes) -> None:
2225
- with self.lock:
2226
- remaining = self.cap - self.used
2227
- if remaining <= 0:
2228
- return
2229
- take = chunk[:remaining]
2230
- handle.write(take)
2231
- self.used += len(take)
2232
- if len(take) < len(chunk) and not self.notified:
2233
- handle.write(b"\n... [job output truncated at 64 MB] ...\n")
2234
- self.notified = True
2235
-
2236
-
2237
- def _jobs_root() -> str:
2238
- os.makedirs(_JOBS_DIR, exist_ok=True)
2239
- return _JOBS_DIR
2240
-
2241
-
2242
- def _job_dir(job_id: str) -> str:
2243
- return os.path.join(_JOBS_DIR, job_id)
2244
-
2245
-
2246
- def _meta_path(job_id: str) -> str:
2247
- return os.path.join(_job_dir(job_id), "meta.json")
2248
-
2249
-
2250
- def _read_meta(job_id: str) -> Optional[Dict[str, Any]]:
2251
- if not job_id or not re.match(r"^[A-Za-z0-9_.-]{1,128}$", job_id):
2252
- return None
2253
- return _read_json(_meta_path(job_id))
2254
-
2255
-
2256
- def _update_meta(job_id: str, **fields: Any) -> Optional[Dict[str, Any]]:
2257
- with _META_LOCK:
2258
- meta = _read_json(_meta_path(job_id))
2259
- if meta is None:
2260
- return None
2261
- meta.update(fields)
2262
- try:
2263
- _write_json_atomic(_meta_path(job_id), meta)
2264
- except Exception:
2265
- return meta
2266
- return meta
2267
-
2268
-
2269
- def _proc_start_time(pid: Any) -> Optional[int]:
2270
- """Start time of a process (field 22 of /proc/<pid>/stat), used to detect PID reuse."""
2271
- try:
2272
- with open("/proc/%d/stat" % int(pid), "rb") as handle:
2273
- data = handle.read()
2274
- rest = data.rsplit(b")", 1)[1].split()
2275
- return int(rest[19])
2276
- except Exception:
2277
- return None
2278
-
2279
-
2280
- def _pid_alive(pid: Any, start_time: Any = None) -> bool:
2281
- try:
2282
- pid_int = int(pid)
2283
- except (TypeError, ValueError):
2284
- return False
2285
- if pid_int <= 0:
2286
- return False
2287
- # After a restart the in-memory entry is gone; without the start time a
2288
- # recycled PID would keep a finished job looking "running" forever.
2289
- if start_time is not None:
2290
- current = _proc_start_time(pid_int)
2291
- if current is None:
2292
- return False
2293
- try:
2294
- if int(start_time) != current:
2295
- return False
2296
- except (TypeError, ValueError):
2297
- pass
2298
- if _IS_WINDOWS:
2299
- return _pid_alive_windows(pid_int)
2300
- try:
2301
- os.kill(pid_int, 0)
2302
- except ProcessLookupError:
2303
- return False
2304
- except PermissionError:
2305
- return True
2306
- except Exception:
2307
- return False
2308
- return True
2309
-
2310
-
2311
- def _pid_alive_windows(pid: int) -> bool:
2312
- """Liveness probe for Windows.
2313
-
2314
- os.kill(pid, 0) must never be used there: any signal other than
2315
- CTRL_C/CTRL_BREAK goes to TerminateProcess, so the "probe" would kill the
2316
- very job it checks.
2317
- """
2318
- try:
2319
- import ctypes
2320
- from ctypes import wintypes
2321
-
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
-
2333
- PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
2334
- STILL_ACTIVE = 259
2335
- handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
2336
- if not handle:
2337
- return False
2338
- try:
2339
- code = wintypes.DWORD()
2340
- if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
2341
- return False
2342
- return code.value == STILL_ACTIVE
2343
- finally:
2344
- kernel32.CloseHandle(handle)
2345
- except Exception:
2346
- return False
2347
-
2348
-
2349
- def _duration_from_meta(meta: Dict[str, Any]) -> Optional[float]:
2350
- started = meta.get("started_at")
2351
- finished = meta.get("finished_at")
2352
- if not started:
2353
- return meta.get("duration")
2354
- try:
2355
- start_ts = datetime.fromisoformat(str(started))
2356
- except Exception:
2357
- return meta.get("duration")
2358
- if finished:
2359
- try:
2360
- end_ts = datetime.fromisoformat(str(finished))
2361
- except Exception:
2362
- end_ts = datetime.now(timezone.utc)
2363
- else:
2364
- end_ts = datetime.now(timezone.utc)
2365
- try:
2366
- return round((end_ts - start_ts).total_seconds(), 3)
2367
- except Exception:
2368
- return meta.get("duration")
2369
-
2370
-
2371
- def _refresh_meta(job_id: str, meta: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
2372
- """Bring a job's persisted status up to date. Never raises."""
2373
- try:
2374
- if meta is None:
2375
- meta = _read_json(_meta_path(job_id))
2376
- if meta is None:
2377
- return None
2378
- if meta.get("status") != "running":
2379
- return meta
2380
-
2381
- entry = _JOBS.get(job_id) or {}
2382
- proc = entry.get("proc")
2383
- if proc is not None:
2384
- returncode = proc.poll()
2385
- if returncode is None:
2386
- return meta
2387
- # The process is gone: let the output pumps drain their pipes so a
2388
- # subsequent file read cannot miss the tail of the output.
2389
- for thread in entry.get("readers", []):
2390
- try:
2391
- thread.join(timeout=0.5)
2392
- except Exception:
2393
- pass
2394
- status = "killed" if meta.get("status") == "killed" else "done"
2395
- return _update_meta(
2396
- job_id,
2397
- status=status,
2398
- exit_code=returncode,
2399
- duration=_duration_from_meta(meta),
2400
- finished_at=meta.get("finished_at") or _now_iso(),
2401
- )
2402
-
2403
- if not _pid_alive(meta.get("pid"), meta.get("pid_start")):
2404
- return _update_meta(
2405
- job_id,
2406
- status="done",
2407
- duration=_duration_from_meta(meta),
2408
- finished_at=meta.get("finished_at") or _now_iso(),
2409
- )
2410
- return meta
2411
- except Exception:
2412
- return meta
2413
-
2414
-
2415
- def _all_metas() -> List[Dict[str, Any]]:
2416
- metas: List[Dict[str, Any]] = []
2417
- try:
2418
- if not os.path.isdir(_JOBS_DIR):
2419
- return metas
2420
- for name in os.listdir(_JOBS_DIR):
2421
- path = os.path.join(_JOBS_DIR, name, "meta.json")
2422
- if not os.path.isfile(path):
2423
- continue
2424
- meta = _read_json(path)
2425
- if meta is not None:
2426
- meta.setdefault("job_id", name)
2427
- metas.append(meta)
2428
- except Exception:
2429
- pass
2430
- return metas
2431
-
2432
-
2433
- def _running_jobs() -> List[Dict[str, Any]]:
2434
- running = []
2435
- for meta in _all_metas():
2436
- refreshed = _refresh_meta(meta.get("job_id", ""), meta)
2437
- if refreshed and refreshed.get("status") == "running":
2438
- running.append(refreshed)
2439
- return running
2440
-
2441
-
2442
- def _pump(stream, path: str, budget: _OutputBudget) -> None:
2443
- try:
2444
- with open(path, "ab", buffering=0) as handle:
2445
- while True:
2446
- chunk = stream.read(65536)
2447
- if not chunk:
2448
- break
2449
- budget.write(handle, chunk)
2450
- except Exception:
2451
- pass
2452
- finally:
2453
- try:
2454
- stream.close()
2455
- except Exception:
2456
- pass
2457
-
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
-
2508
- def _reap(job_id: str, proc: "subprocess.Popen", started_mono: float) -> None:
2509
- """Background thread: collect a finished job and persist its result."""
2510
- try:
2511
- returncode = proc.wait()
2512
- except Exception:
2513
- returncode = None
2514
-
2515
- entry = _JOBS.get(job_id) or {}
2516
- for thread in entry.get("readers", []):
2517
- try:
2518
- thread.join(timeout=5)
2519
- except Exception:
2520
- pass
2521
-
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)
2548
-
2549
-
2550
- def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
2551
- try:
2552
- command = args.get("command")
2553
- if not isinstance(command, str) or not command.strip():
2554
- return {"error": "command is required and must be a non-empty string"}
2555
-
2556
- if len(_running_jobs()) >= MAX_RUNNING_JOBS:
2557
- return {"error": "too many running jobs (limit %d)" % MAX_RUNNING_JOBS}
2558
-
2559
- cwd = None
2560
- if args.get("cwd"):
2561
- cwd = _resolve(args.get("cwd"))
2562
- if not os.path.isdir(cwd):
2563
- return {"error": "cwd is not a directory: %s" % cwd}
2564
- if cwd is None:
2565
- cwd = _WORKSPACE if os.path.isdir(_WORKSPACE) else None
2566
-
2567
- job_id = uuid.uuid4().hex
2568
- job_dir = _job_dir(job_id)
2569
- os.makedirs(job_dir, exist_ok=True)
2570
- stdout_path = os.path.join(job_dir, "stdout.log")
2571
- stderr_path = os.path.join(job_dir, "stderr.log")
2572
- for path in (stdout_path, stderr_path):
2573
- with open(path, "ab"):
2574
- pass
2575
-
2576
- try:
2577
- proc = subprocess.Popen(
2578
- _shell_argv(command),
2579
- cwd=cwd,
2580
- stdin=subprocess.DEVNULL,
2581
- stdout=subprocess.PIPE,
2582
- stderr=subprocess.PIPE,
2583
- bufsize=0,
2584
- start_new_session=not _IS_WINDOWS,
2585
- creationflags=_POPEN_FLAGS,
2586
- env=_child_env(),
2587
- )
2588
- except Exception as exc:
2589
- return {"error": "failed to start job: %s" % exc}
2590
-
2591
- started_at = _now_iso()
2592
- budget = _OutputBudget()
2593
- readers = [
2594
- threading.Thread(target=_pump, args=(proc.stdout, stdout_path, budget), daemon=True),
2595
- threading.Thread(target=_pump, args=(proc.stderr, stderr_path, budget), daemon=True),
2596
- ]
2597
- for thread in readers:
2598
- thread.start()
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()
2604
- _JOBS[job_id] = {
2605
- "proc": proc,
2606
- "readers": readers,
2607
- "started_mono": started_mono,
2608
- "pid_start": _proc_start_time(proc.pid),
2609
- }
2610
-
2611
- meta = {
2612
- "job_id": job_id,
2613
- "command": command,
2614
- "pid": proc.pid,
2615
- "pid_start": _proc_start_time(proc.pid),
2616
- "cwd": cwd,
2617
- "started_at": started_at,
2618
- "status": "running",
2619
- "exit_code": None,
2620
- "duration": None,
2621
- "finished_at": None,
2622
- "stdout_path": stdout_path,
2623
- "stderr_path": stderr_path,
2624
- "wake_lock": bool(wake_lock),
2625
- }
2626
- try:
2627
- _write_json_atomic(_meta_path(job_id), meta)
2628
- except Exception as exc:
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
2637
- return {"error": "failed to persist job metadata: %s" % exc}
2638
-
2639
- reaper = threading.Thread(target=_reap, args=(job_id, proc, started_mono), daemon=True)
2640
- reaper.start()
2641
- _JOBS[job_id]["reaper"] = reaper
2642
-
2643
- result = {
2644
- "job_id": job_id,
2645
- "pid": proc.pid,
2646
- "pid_start": meta.get("pid_start"),
2647
- "command": command,
2648
- "started_at": started_at,
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
2658
- except Exception as exc:
2659
- return {"error": "run_job failed: %s" % exc}
2660
-
2661
-
2662
- def _wait_for_job(job_id: str, wait_ms: int) -> None:
2663
- deadline = time.time() + (wait_ms / 1000.0)
2664
- while True:
2665
- meta = _refresh_meta(job_id)
2666
- if meta is None or meta.get("status") != "running":
2667
- return
2668
- if time.time() >= deadline:
2669
- return
2670
- entry = _JOBS.get(job_id) or {}
2671
- proc = entry.get("proc")
2672
- remaining = max(0.0, deadline - time.time())
2673
- if proc is not None:
2674
- try:
2675
- proc.wait(timeout=min(remaining, 0.2) or 0.01)
2676
- except Exception:
2677
- pass
2678
- else:
2679
- time.sleep(min(0.1, remaining or 0.01))
2680
-
2681
-
2682
- def _tool_job_output(args: Dict[str, Any]) -> Dict[str, Any]:
2683
- try:
2684
- job_id = args.get("job_id")
2685
- if not isinstance(job_id, str) or not job_id.strip():
2686
- return {"error": "job_id is required"}
2687
- job_id = job_id.strip()
2688
- meta = _read_meta(job_id)
2689
- if meta is None:
2690
- return {"error": "job not found: %s" % job_id}
2691
-
2692
- wait_ms = _clamp_int(args.get("wait_ms"), 0, MAX_WAIT_MS, 0)
2693
- if wait_ms:
2694
- _wait_for_job(job_id, wait_ms)
2695
- meta = _refresh_meta(job_id) or meta
2696
-
2697
- stdout_path = meta.get("stdout_path") or os.path.join(_job_dir(job_id), "stdout.log")
2698
- stderr_path = meta.get("stderr_path") or os.path.join(_job_dir(job_id), "stderr.log")
2699
-
2700
- def _read_text(path: str) -> str:
2701
- try:
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()))
2707
- except Exception:
2708
- return ""
2709
-
2710
- stdout_full = _read_text(stdout_path)
2711
- stderr_full = _read_text(stderr_path)
2712
-
2713
- max_chars = _max_chars(args.get("max_chars"))
2714
- cursor = max(0, _safe_int(args.get("cursor"), 0))
2715
- stdout_part, truncated, next_cursor = _paginate(stdout_full, cursor, max_chars)
2716
- stderr_part = stderr_full[:max_chars]
2717
- stderr_truncated = len(stderr_full) > max_chars
2718
-
2719
- return {
2720
- "status": meta.get("status", "running"),
2721
- "exit_code": meta.get("exit_code"),
2722
- "stdout": stdout_part,
2723
- "stderr": stderr_part,
2724
- "next_cursor": next_cursor,
2725
- "duration": meta.get("duration") if meta.get("duration") is not None else _duration_from_meta(meta),
2726
- "truncated": bool(truncated or stderr_truncated),
2727
- }
2728
- except Exception as exc:
2729
- return {"error": "job_output failed: %s" % exc}
2730
-
2731
-
2732
- # Windows' signal module has no SIGKILL/SIGHUP/SIGQUIT: a literal dict of them
2733
- # raised AttributeError at import, so the whole node failed to start there.
2734
- _SIGNALS = {
2735
- name: getattr(_signal, "SIG" + name)
2736
- for name in ("TERM", "KILL", "INT", "HUP", "QUIT")
2737
- if hasattr(_signal, "SIG" + name)
2738
- }
2739
- if _IS_WINDOWS:
2740
- # Every name is accepted on Windows; the job is ended with a tree kill.
2741
- for _name in ("TERM", "KILL", "INT", "HUP", "QUIT"):
2742
- _SIGNALS.setdefault(_name, _signal.SIGTERM)
2743
-
2744
-
2745
- def _kill_tree_windows(pid: int) -> None:
2746
- """End a job and its children on Windows (there are no process groups to signal)."""
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)
2763
-
2764
-
2765
- def _tool_job_kill(args: Dict[str, Any]) -> Dict[str, Any]:
2766
- try:
2767
- if _READ_ONLY:
2768
- return {"error": "server is in read-only mode"}
2769
- job_id = args.get("job_id")
2770
- if not isinstance(job_id, str) or not job_id.strip():
2771
- return {"error": "job_id is required"}
2772
- job_id = job_id.strip()
2773
- meta = _read_meta(job_id)
2774
- if meta is None:
2775
- return {"error": "job not found: %s" % job_id}
2776
- meta = _refresh_meta(job_id) or meta
2777
-
2778
- raw_signal = args.get("signal", "TERM")
2779
- if isinstance(raw_signal, int) and not isinstance(raw_signal, bool):
2780
- sig = raw_signal
2781
- else:
2782
- sig = _SIGNALS.get(str(raw_signal).strip().upper())
2783
- if sig is None:
2784
- return {"error": "unsupported signal: %r" % (raw_signal,)}
2785
-
2786
- if meta.get("status") != "running":
2787
- return {"ok": True, "status": meta.get("status", "done")}
2788
-
2789
- pid = meta.get("pid")
2790
- delivered = False
2791
- if _IS_WINDOWS:
2792
- try:
2793
- _kill_tree_windows(int(pid))
2794
- delivered = True
2795
- except Exception as exc:
2796
- return {"error": "failed to stop job %s: %s" % (job_id, exc)}
2797
- else:
2798
- try:
2799
- os.killpg(os.getpgid(int(pid)), sig)
2800
- delivered = True
2801
- except Exception:
2802
- try:
2803
- os.kill(int(pid), sig)
2804
- delivered = True
2805
- except Exception as exc:
2806
- return {"error": "failed to signal job %s: %s" % (job_id, exc)}
2807
-
2808
- if delivered:
2809
- _update_meta(job_id, status="killed", finished_at=_now_iso(), duration=_duration_from_meta(meta))
2810
- _release_job_wake_lock(job_id)
2811
- return {"ok": True, "status": "killed"}
2812
- except Exception as exc:
2813
- return {"error": "job_kill failed: %s" % exc}
2814
-
2815
-
2816
- def _tool_job_list(args: Dict[str, Any]) -> Dict[str, Any]:
2817
- try:
2818
- metas = []
2819
- for meta in _all_metas():
2820
- refreshed = _refresh_meta(meta.get("job_id", ""), meta) or meta
2821
- metas.append(refreshed)
2822
- metas.sort(key=lambda item: str(item.get("started_at") or ""), reverse=True)
2823
- # The full history is unbounded (one entry per job ever started), and every
2824
- # byte here lands in the model's context. Show the most recent few by
2825
- # default and let the caller ask for more explicitly.
2826
- try:
2827
- limit = int(args.get("limit") or 20)
2828
- except (TypeError, ValueError):
2829
- limit = 20
2830
- limit = max(1, min(limit, 50))
2831
- jobs = []
2832
- for meta in metas[:limit]:
2833
- command = str(meta.get("command") or "")
2834
- if len(command) > 80:
2835
- command = command[:77] + "..."
2836
- jobs.append({
2837
- "job_id": meta.get("job_id"),
2838
- "command": command,
2839
- "status": meta.get("status", "running"),
2840
- "exit_code": meta.get("exit_code"),
2841
- "duration": meta.get("duration"),
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),
2846
- })
2847
- return {"jobs": jobs, "count": len(jobs), "total": len(metas),
2848
- "note": ("showing the %d most recent of %d; pass limit=<n> for more"
2849
- % (len(jobs), len(metas))) if len(metas) > len(jobs) else None}
2850
- except Exception as exc:
2851
- return {"error": "job_list failed: %s" % exc}
2852
-
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
-
2911
- # ---------------------------------------------------------------------------
2912
- # Tool definitions (MCP schemas)
2913
- # ---------------------------------------------------------------------------
2914
-
2915
- def _schema(properties: Dict[str, Any], required: Optional[List[str]] = None) -> Dict[str, Any]:
2916
- schema: Dict[str, Any] = {"type": "object", "properties": properties}
2917
- if required:
2918
- schema["required"] = required
2919
- schema["additionalProperties"] = False
2920
- return schema
2921
-
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
-
3033
- TOOLS: List[Dict[str, Any]] = [
3034
- {
3035
- "name": "mesh_status",
3036
- "title": "Mesh Status",
3037
- "description": ("Confirm this node is reachable. Call this first if you think the host is "
3038
- "offline; it reports live evidence from the host itself, plus what kind "
3039
- "of machine answered (device_class, scenario) and its battery level."),
3040
- "inputSchema": _schema({}),
3041
- },
3042
- {
3043
- "name": "system_info",
3044
- "title": "System Info",
3045
- "description": ("One-call host summary: OS, desktop, user, home, disks, memory, load, "
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."),
3052
- "inputSchema": _schema({}),
3053
- },
3054
- {
3055
- "name": "system_vitals",
3056
- "title": "System Vitals",
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)."),
3059
- "inputSchema": _schema({}),
3060
- },
3061
- {
3062
- "name": "get_orchestration_skill",
3063
- "title": "Get Orchestration Skill",
3064
- "description": "Load the current Antigravity orchestration skill and rules.",
3065
- "inputSchema": _schema({}),
3066
- },
3067
- {
3068
- "name": "list_dir",
3069
- "title": "List Directory",
3070
- "description": "List files and directories at the given path (workspace by default).",
3071
- "inputSchema": _schema({"path": {"type": "string", "description": "Directory or file path."}}),
3072
- },
3073
- {
3074
- "name": "bash_exec",
3075
- "title": "Execute Bash Command",
3076
- "description": (
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 "
3081
- "cut, call again with cursor=next_cursor to continue (nothing is dropped). Large "
3082
- "outputs (above 2 MB) are spooled to a file returned in saved_to."
3083
- ),
3084
- "inputSchema": _schema(
3085
- {
3086
- "command": {"type": "string", "description": "Shell command to execute."},
3087
- "timeout_sec": {"type": "integer", "description": "Timeout in seconds (1-120, default 25)."},
3088
- "max_chars": {"type": "integer", "description": "Maximum characters per response."},
3089
- "cursor": {"type": "integer", "description": "Character offset to continue from."},
3090
- },
3091
- ["command"],
3092
- ),
3093
- },
3094
- {
3095
- "name": "read_file",
3096
- "title": "Read File",
3097
- "description": (
3098
- "Read a text file with %4d | line numbers, optionally restricted to a line range. "
3099
- "Supports max_chars/cursor pagination."
3100
- ),
3101
- "inputSchema": _schema(
3102
- {
3103
- "path": {"type": "string", "description": "File path to read."},
3104
- "start_line": {"type": "integer", "description": "First line (1-based)."},
3105
- "end_line": {"type": "integer", "description": "Last line (inclusive)."},
3106
- "max_chars": {"type": "integer", "description": "Maximum characters per response."},
3107
- "cursor": {"type": "integer", "description": "Character offset to continue from."},
3108
- },
3109
- ["path"],
3110
- ),
3111
- },
3112
- {
3113
- "name": "write_file",
3114
- "title": "Write File",
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
- ),
3119
- "inputSchema": _schema(
3120
- {
3121
- "path": {"type": "string", "description": "File path to write."},
3122
- "content": {"type": "string", "description": "Full file content."},
3123
- "create_dirs": {"type": "boolean", "description": "Create parent directories (default true)."},
3124
- "mode": {"type": "string",
3125
- "description": "Octal file mode, e.g. \"0644\". POSIX only; ignored on Windows."},
3126
- },
3127
- ["path", "content"],
3128
- ),
3129
- },
3130
- {
3131
- "name": "edit_file",
3132
- "title": "Edit File",
3133
- "description": (
3134
- "Replace an exact substring in a file. old_string must match exactly once unless "
3135
- "replace_all is set. Optional expected_sha256 guards against overwriting a file "
3136
- "that changed since it was read."
3137
- ),
3138
- "inputSchema": _schema(
3139
- {
3140
- "path": {"type": "string", "description": "File path to edit."},
3141
- "old_string": {"type": "string", "description": "Exact text to replace."},
3142
- "new_string": {"type": "string", "description": "Replacement text."},
3143
- "expected_sha256": {"type": "string", "description": "Expected current sha256 of the file."},
3144
- "replace_all": {"type": "boolean", "description": "Replace every occurrence."},
3145
- },
3146
- ["path", "old_string", "new_string"],
3147
- ),
3148
- },
3149
- {
3150
- "name": "grep_search",
3151
- "title": "Grep Search",
3152
- "description": "Recursively search file contents; skips binary files and heavy directories.",
3153
- "inputSchema": _schema(
3154
- {
3155
- "pattern": {"type": "string", "description": "Regular expression (or literal text with fixed)."},
3156
- "path": {"type": "string", "description": "Root directory or file (default workspace)."},
3157
- "glob": {"type": "string", "description": "File name filter, e.g. *.py."},
3158
- "limit": {"type": "integer", "description": "Maximum matches (1-1000, default 200)."},
3159
- "ignore_case": {"type": "boolean", "description": "Case-insensitive search."},
3160
- "fixed": {"type": "boolean", "description": "Treat pattern as literal text."},
3161
- "context": {"type": "integer", "description": "Context lines around each match."},
3162
- },
3163
- ["pattern"],
3164
- ),
3165
- },
3166
- {
3167
- "name": "glob_find",
3168
- "title": "Glob Find",
3169
- "description": "Find files by glob pattern (* and **).",
3170
- "inputSchema": _schema(
3171
- {
3172
- "pattern": {"type": "string", "description": "Glob pattern, e.g. **/*.py."},
3173
- "path": {"type": "string", "description": "Root directory (default workspace)."},
3174
- },
3175
- ["pattern"],
3176
- ),
3177
- },
3178
- {
3179
- "name": "run_job",
3180
- "title": "Run Background Job",
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.",
3184
- "inputSchema": _schema(
3185
- {
3186
- "command": {"type": "string", "description": "Shell command to run in the background."},
3187
- "cwd": {"type": "string", "description": "Working directory (default workspace)."},
3188
- },
3189
- ["command"],
3190
- ),
3191
- },
3192
- {
3193
- "name": "job_output",
3194
- "title": "Job Output",
3195
- "description": "Read a background job's output, optionally waiting up to 20 s for completion.",
3196
- "inputSchema": _schema(
3197
- {
3198
- "job_id": {"type": "string", "description": "Job id returned by run_job."},
3199
- "wait_ms": {"type": "integer", "description": "Milliseconds to wait (0-20000)."},
3200
- "max_chars": {"type": "integer", "description": "Maximum stdout characters per response."},
3201
- "cursor": {"type": "integer", "description": "stdout character offset to continue from."},
3202
- },
3203
- ["job_id"],
3204
- ),
3205
- },
3206
- {
3207
- "name": "job_kill",
3208
- "title": "Job Kill",
3209
- "description": "Terminate a running background job.",
3210
- "inputSchema": _schema(
3211
- {
3212
- "job_id": {"type": "string", "description": "Job id returned by run_job."},
3213
- "signal": {"type": "string", "description": "TERM (default), KILL, INT, HUP or QUIT."},
3214
- },
3215
- ["job_id"],
3216
- ),
3217
- },
3218
- {
3219
- "name": "job_list",
3220
- "title": "Job List",
3221
- "description": "List recent background jobs, newest first (20 by default, max 50).",
3222
- "inputSchema": _schema({
3223
- "limit": {"type": "integer", "description": "How many jobs to return (1-50, default 20)."},
3224
- }),
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
- },
3432
- ]
3433
-
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
-
3447
- # ---------------------------------------------------------------------------
3448
- # Dispatch
3449
- # ---------------------------------------------------------------------------
3450
-
3451
- _HANDLERS: Dict[str, Callable[[Dict[str, Any]], Any]] = {
3452
- "mesh_status": _tool_mesh_status,
3453
- "mesh_update": _tool_mesh_update,
3454
- "system_info": _tool_system_info,
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,
3460
- "get_orchestration_skill": _tool_get_orchestration_skill,
3461
- "list_dir": _tool_list_dir,
3462
- "bash_exec": _tool_bash_exec,
3463
- "read_file": _tool_read_file,
3464
- "write_file": _tool_write_file,
3465
- "edit_file": _tool_edit_file,
3466
- "grep_search": _tool_grep_search,
3467
- "glob_find": _tool_glob_find,
3468
- "run_job": _tool_run_job,
3469
- "job_output": _tool_job_output,
3470
- "job_kill": _tool_job_kill,
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,
3479
- }
3480
-
3481
-
3482
- def call_tool(name: str, args: Any = None) -> Any:
3483
- """Dispatch a tool call. Never raises: any failure becomes ``{"error": ...}``."""
3484
- try:
3485
- handler = _HANDLERS.get(name) if isinstance(name, str) else None
3486
- if handler is None:
3487
- return {"error": "Unknown tool: %s" % (name,)}
3488
- if args is None:
3489
- args = {}
3490
- if not isinstance(args, dict):
3491
- return {"error": "arguments must be an object"}
3492
- result = handler(args)
3493
- if result is None:
3494
- return {"error": "tool %s returned no result" % name}
3495
- return result
3496
- except Exception as exc: # pragma: no cover - defensive
3497
- return {"error": "%s: %s" % (type(exc).__name__, exc)}