gemini-computer-use 0.4.3 → 0.4.5

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/termux.py DELETED
@@ -1,916 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """core/termux.py - the one place in the node that runs ``termux-*`` commands.
4
-
5
- Termux:API is a two-part install: the ``termux-api`` package supplies the shell
6
- commands, and the Termux:API **app** answers them over an Android binder. Either
7
- half missing produces the same silent nothing from the phone, so every
8
- ``termux-*`` call in this project goes through this module and every failure
9
- comes back as a dict a model can act on instead of an exception nobody catches:
10
-
11
- * ``missing`` - the command is not on ``PATH`` (the package or the app is absent);
12
- * ``denied`` - Android refused the permission behind the call;
13
- * ``timeout`` - Android froze the Termux:API app, which is the *normal* failure
14
- mode once the screen has been off for a while;
15
- * ``refused`` - the caller asked for something that is not a ``termux-*``
16
- command, which must never reach an exec.
17
-
18
- Contract:
19
-
20
- * **nothing raises** - a public function always returns a dict;
21
- * **no shell, ever** - every command is an argv list under a hard timeout, so a
22
- string that arrived from a model can never become shell syntax;
23
- * **cheap** - a missing command is answered from ``shutil.which`` without
24
- starting a process, and the command inventory is cached for a minute;
25
- * **one root cause, said once** - when every command hangs because the
26
- Termux:API *app* is missing, that is remembered (for a short while) instead of
27
- making each telemetry section report its own timeout;
28
- * **silent** - no printing, no logging, no progress output.
29
-
30
- The wake lock lives here for the same reason the rest does: Android freezes
31
- background processes when the screen goes off, and the node's own boot script
32
- takes the lock exactly once at boot with nobody left to release it.
33
- """
34
-
35
- from __future__ import annotations
36
-
37
- import json
38
- import locale as _locale
39
- import os
40
- import shutil
41
- import subprocess
42
- import threading
43
- import time
44
- from typing import Any, Dict, List, Optional, Sequence, Tuple
45
-
46
- #: The one sentence a user needs when a ``termux-*`` command is missing. Both
47
- #: halves matter: an app from a *different* store than the package fails to bind,
48
- #: which looks exactly like "the command is not installed".
49
- TERMUX_API_FIX = ("install the Termux:API app from the SAME store as Termux "
50
- "(F-Droid or GitHub, never mixed) and run `pkg install termux-api`")
51
-
52
- #: Told whenever a command exists but never answers. Android freezes the
53
- #: Termux:API app in the background, and the binder call then waits forever.
54
- TERMUX_TIMEOUT_FIX = ("open the Termux:API app once after installing it, and disable "
55
- "battery optimisation for Termux and Termux:API")
56
-
57
- #: Told when ``~/storage/shared`` is missing: that symlink is the only reason the
58
- #: node can read or write the phone's own storage.
59
- TERMUX_STORAGE_FIX = ("run `termux-setup-storage` in Termux and grant the storage "
60
- "permission it asks for")
61
-
62
- #: Every Termux:API command this project may use. Kept in one tuple so the
63
- #: inventory a model receives and the allowlist the runner enforces cannot drift.
64
- REQUIRED_COMMANDS: Tuple[str, ...] = (
65
- "termux-battery-status",
66
- "termux-brightness",
67
- "termux-call-log",
68
- "termux-camera-info",
69
- "termux-camera-photo",
70
- "termux-clipboard-get",
71
- "termux-clipboard-set",
72
- "termux-contact-list",
73
- "termux-dialog",
74
- "termux-download",
75
- "termux-fingerprint",
76
- "termux-infrared-frequencies",
77
- "termux-infrared-transmit",
78
- "termux-job-scheduler",
79
- "termux-location",
80
- "termux-media-player",
81
- "termux-media-scan",
82
- "termux-microphone-record",
83
- "termux-notification",
84
- "termux-notification-list",
85
- "termux-notification-remove",
86
- "termux-open",
87
- "termux-open-url",
88
- "termux-sensor",
89
- "termux-share",
90
- "termux-sms-list",
91
- "termux-sms-send",
92
- "termux-storage-get",
93
- "termux-telephony-call",
94
- "termux-telephony-cellinfo",
95
- "termux-telephony-deviceinfo",
96
- "termux-toast",
97
- "termux-torch",
98
- "termux-tts-engines",
99
- "termux-tts-speak",
100
- "termux-usb",
101
- "termux-vibrate",
102
- "termux-volume",
103
- "termux-wallpaper",
104
- "termux-wake-lock",
105
- "termux-wake-unlock",
106
- "termux-wifi-connectioninfo",
107
- "termux-wifi-enable",
108
- "termux-wifi-scaninfo",
109
- )
110
-
111
- #: Without these three a phone cannot even be described: battery, clipboard and
112
- #: the notification channel. Their absence is what "the API is not installed"
113
- #: means to a user, so a partial install is reported as not installed.
114
- MANDATORY_COMMANDS: Tuple[str, ...] = (
115
- "termux-battery-status",
116
- "termux-clipboard-get",
117
- "termux-notification",
118
- )
119
-
120
- #: The Android permission groups behind the commands above. They start as
121
- #: "unknown" and are only ever changed by a real observation - probing the camera
122
- #: or the microphone just to learn a state would open hardware the caller never
123
- #: asked for, which is a side effect a status call must not have.
124
- PERMISSION_APIS: Tuple[str, ...] = (
125
- "biometric",
126
- "call_log",
127
- "camera",
128
- "clipboard",
129
- "contacts",
130
- "location",
131
- "microphone",
132
- "notification",
133
- "sensors",
134
- "sms",
135
- "storage",
136
- "telephony",
137
- "usb",
138
- "wifi",
139
- )
140
-
141
- #: Which permission a failing command was exercising. A command missing from this
142
- #: map still reports ``denied``; it just cannot name the permission to remember.
143
- _COMMAND_API: Dict[str, str] = {
144
- "termux-call-log": "call_log",
145
- "termux-camera-info": "camera",
146
- "termux-camera-photo": "camera",
147
- "termux-clipboard-get": "clipboard",
148
- "termux-clipboard-set": "clipboard",
149
- "termux-contact-list": "contacts",
150
- "termux-fingerprint": "biometric",
151
- "termux-location": "location",
152
- "termux-microphone-record": "microphone",
153
- "termux-notification": "notification",
154
- "termux-notification-list": "notification",
155
- "termux-notification-remove": "notification",
156
- "termux-sensor": "sensors",
157
- "termux-sms-list": "sms",
158
- "termux-sms-send": "sms",
159
- "termux-storage-get": "storage",
160
- "termux-telephony-call": "telephony",
161
- "termux-telephony-cellinfo": "telephony",
162
- "termux-telephony-deviceinfo": "telephony",
163
- "termux-usb": "usb",
164
- "termux-wifi-connectioninfo": "wifi",
165
- "termux-wifi-enable": "wifi",
166
- "termux-wifi-scaninfo": "wifi",
167
- }
168
-
169
- #: Failure text from the Termux:API app that always means "Android refused this
170
- #: call", whichever API is behind it.
171
- _PERMISSION_PHRASES: Tuple[str, ...] = (
172
- "permission denied",
173
- "not granted",
174
- "requires permission",
175
- "securityexception",
176
- "location services",
177
- "user denied",
178
- "no such file or directory: /dev/video",
179
- # The wording a real phone used (OPPO PHY110, Android 16), delivered as JSON on
180
- # stdout with exit code 0: {"error": "Please grant the following permission to
181
- # use this command: android.permission.ACCESS_COARSE_LOCATION"}.
182
- "please grant",
183
- )
184
-
185
- #: Words that turn a plain noun ("camera", "microphone") into a permission
186
- #: failure. The nouns alone are not enough: "no camera found" is a hardware
187
- #: answer, not a permission one, and must not be reported as "grant this".
188
- _PERMISSION_CONTEXT: Tuple[str, ...] = (
189
- "permission",
190
- "denied",
191
- "not granted",
192
- "securityexception",
193
- "security exception",
194
- "not allowed",
195
- "forbidden",
196
- "unauthorized",
197
- "requires",
198
- )
199
-
200
- #: Where Termux really lives. ``$PREFIX`` is not trusted on its own - many build
201
- #: systems export it - so the application data path is an independent signal.
202
- _TERMUX_BIN_DIR = "/data/data/com.termux/files/usr/bin"
203
-
204
- #: Default budget for one ``termux-*`` command, in seconds.
205
- DEFAULT_TIMEOUT = 6.0
206
-
207
- #: Wake-lock calls do not return data, so they get the same budget as anything else.
208
- WAKE_LOCK_TIMEOUT = 6.0
209
-
210
- #: The command inventory is a property of the install, not of the moment: a minute
211
- #: of caching keeps a chatty model from running 44 PATH lookups per tool call.
212
- _CACHE_TTL = 60.0
213
-
214
- #: How long one timeout counts as evidence that the Termux:API **app** is
215
- #: unreachable. Short on purpose: the operator may install the app, or Android may
216
- #: unfreeze it, at any moment, and stale evidence is worse than no evidence.
217
- APP_EVIDENCE_TTL = 120.0
218
-
219
- _ACQUIRE = "termux-wake-lock"
220
- _RELEASE = "termux-wake-unlock"
221
- _WAKE_ACTIONS: Dict[str, str] = {"acquire": _ACQUIRE, "release": _RELEASE}
222
-
223
- #: Windows: keep a console window from flashing for a phone command that cannot
224
- #: exist there anyway (the flag is 0 everywhere else).
225
- _POPEN_FLAGS = 0x08000000 if os.name == "nt" else 0
226
-
227
- _CACHE: Dict[str, Tuple[float, Dict[str, Any]]] = {}
228
- _CACHE_LOCK = threading.RLock()
229
-
230
- #: ``api_installed``/``capabilities`` caches and the observation-backed permission
231
- #: map are separate locks: a status call must not wait on a camera probe.
232
- _PERMISSIONS: Dict[str, str] = {name: "unknown" for name in PERMISSION_APIS}
233
- _PERMISSION_LOCK = threading.RLock()
234
-
235
- #: Is the Termux:API *app* answering? The commands can all exist while the app is
236
- #: missing, and then every one of them hangs: on a real phone that cost one
237
- #: timeout per telemetry section, so the node has to remember the shared cause
238
- #: instead of reporting five unrelated "timed out" blocks. Guarded by ``_CACHE_LOCK``
239
- #: because it is capability evidence, exactly like the cached blocks beside it.
240
- _APP_SUCCESS = False #: a call has been answered since the last reset
241
- _APP_TIMEOUT_COMMAND = "" #: which command last hung (kept for diagnostics)
242
- _APP_TIMEOUT_AT = 0.0 #: ``time.monotonic()`` of that hang
243
-
244
- #: How many times *this process* has taken the wake lock. Held across the command
245
- #: itself, because "exactly one acquire and one release" is the invariant Android
246
- #: cares about and a race between two threads would break it.
247
- _WAKE_COUNT = 0
248
- _WAKE_LOCK = threading.RLock()
249
-
250
-
251
- # ---------------------------------------------------------------------------
252
- # Seams: the two things tests replace, plus decoding
253
- # ---------------------------------------------------------------------------
254
-
255
- _DECODINGS: Optional[List[str]] = None
256
-
257
-
258
- def _windows_code_pages() -> List[str]:
259
- """Code pages a Windows child may write with: OEM console CP first, then ANSI."""
260
- pages: List[str] = []
261
- try:
262
- import ctypes
263
-
264
- for getter in ("GetOEMCP", "GetACP"):
265
- try:
266
- code_page = int(getattr(ctypes.windll.kernel32, getter)())
267
- except Exception:
268
- continue
269
- if code_page > 0:
270
- pages.append("cp%d" % code_page)
271
- except Exception:
272
- pass
273
- return pages
274
-
275
-
276
- def _decodings() -> List[str]:
277
- """Encodings to try for captured child output, most likely first.
278
-
279
- Order matters: every single-byte code page "succeeds" at decoding anything, so
280
- UTF-8 (what Termux writes) has to be tried before the Windows OEM page (what
281
- cmd.exe and the native console tools write) and before ANSI, the last resort.
282
- """
283
- global _DECODINGS
284
- if _DECODINGS is not None:
285
- return _DECODINGS
286
- candidates = ["utf-8"]
287
- if os.name == "nt":
288
- candidates.extend(_windows_code_pages())
289
- else:
290
- try:
291
- candidates.append(_locale.getpreferredencoding(False))
292
- except Exception:
293
- pass
294
- candidates.append("cp1252")
295
- unique: List[str] = []
296
- seen = set()
297
- for name in candidates:
298
- if not name:
299
- continue
300
- key = name.lower()
301
- if key in seen:
302
- continue
303
- seen.add(key)
304
- unique.append(name)
305
- _DECODINGS = unique
306
- return unique
307
-
308
-
309
- def _decode(data: Any) -> str:
310
- """Decode captured bytes without mojibake, with line endings normalised.
311
-
312
- A stray ``\\r`` would end up inside a path or a JSON key on the way back to a
313
- model, so ``\\r\\n`` and lone ``\\r`` both become ``\\n`` here rather than in
314
- every reader.
315
- """
316
- if data is None:
317
- return ""
318
- if isinstance(data, str):
319
- return data.replace("\r\n", "\n").replace("\r", "\n").strip()
320
- if isinstance(data, (bytes, bytearray)):
321
- for encoding in _decodings():
322
- try:
323
- return (bytes(data).decode(encoding).replace("\r\n", "\n")
324
- .replace("\r", "\n").strip())
325
- except (UnicodeDecodeError, LookupError):
326
- continue
327
- return bytes(data).decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "\n").strip()
328
- return str(data).strip()
329
-
330
-
331
- def _which(name: str) -> Optional[str]:
332
- """``shutil.which`` behind a seam, so tests never need a PATH shim."""
333
- try:
334
- return shutil.which(name)
335
- except Exception:
336
- return None
337
-
338
-
339
- def _run(argv: Sequence[str], timeout: float) -> Tuple[int, str]:
340
- """Run one argv list: no shell, no stdin, and a hard timeout.
341
-
342
- ``subprocess.TimeoutExpired`` is deliberately **not** swallowed here: this
343
- module exists to explain a frozen Termux:API app, so the caller has to turn
344
- that exception into a timeout answer instead of a silent empty result. Any
345
- other failure to start the process is ``(-1, "")``, which the caller reports
346
- as a reason.
347
- """
348
- try:
349
- proc = subprocess.run(
350
- list(argv),
351
- stdin=subprocess.DEVNULL,
352
- stdout=subprocess.PIPE,
353
- stderr=subprocess.PIPE,
354
- timeout=max(0.2, float(timeout)),
355
- creationflags=_POPEN_FLAGS,
356
- )
357
- except subprocess.TimeoutExpired:
358
- raise
359
- except Exception:
360
- return -1, ""
361
- out = _decode(proc.stdout)
362
- if proc.returncode != 0:
363
- # A failing Termux:API command explains itself on stderr. On success stdout
364
- # is the answer - and an empty stdout is a legitimate empty clipboard, so
365
- # stderr is never substituted there.
366
- return int(proc.returncode), (_decode(proc.stderr) or out)
367
- return int(proc.returncode), out
368
-
369
-
370
- # ---------------------------------------------------------------------------
371
- # Platform detection and the command inventory
372
- # ---------------------------------------------------------------------------
373
-
374
- def is_android() -> bool:
375
- """True only inside Termux on Android.
376
-
377
- Three independent signals, any of which is enough: Termux exports
378
- ``TERMUX_VERSION``, its ``$PREFIX`` points inside ``com.termux``, and its
379
- application data directory has a fixed path. A generic ``PREFIX`` such as
380
- ``/usr/local`` is not a signal - build systems export it everywhere.
381
- """
382
- try:
383
- if os.environ.get("TERMUX_VERSION"):
384
- return True
385
- if "com.termux" in os.environ.get("PREFIX", ""):
386
- return True
387
- return os.path.isdir(_TERMUX_BIN_DIR)
388
- except Exception:
389
- return False
390
-
391
-
392
- def api_installed(refresh: bool = False) -> Dict[str, Any]:
393
- """Which Termux:API commands this phone actually has, cached for a minute.
394
-
395
- ``installed`` answers the only question a user asks ("is the API working?"),
396
- which needs the mandatory trio; ``commands`` answers the one a model asks
397
- ("may I try termux-sensor?"). ``refresh=True`` is for the moment right after
398
- the installer ran ``pkg install termux-api``.
399
- """
400
- now = time.monotonic()
401
- if not refresh:
402
- with _CACHE_LOCK:
403
- hit = _CACHE.get("api_installed")
404
- if hit and hit[0] > now:
405
- return hit[1]
406
- try:
407
- commands = {name: bool(_which(name)) for name in REQUIRED_COMMANDS}
408
- missing = [name for name in REQUIRED_COMMANDS if not commands[name]]
409
- report: Dict[str, Any] = {
410
- "installed": all(commands.get(name, False) for name in MANDATORY_COMMANDS),
411
- "commands": commands,
412
- "missing": missing,
413
- "fix": TERMUX_API_FIX,
414
- "source": "shutil.which",
415
- }
416
- except Exception as exc: # a probe must never escape
417
- report = {
418
- "installed": False,
419
- "commands": {name: False for name in REQUIRED_COMMANDS},
420
- "missing": list(REQUIRED_COMMANDS),
421
- "fix": TERMUX_API_FIX,
422
- "source": "shutil.which",
423
- "error": "%s: %s" % (type(exc).__name__, exc),
424
- }
425
- with _CACHE_LOCK:
426
- _CACHE["api_installed"] = (now + _CACHE_TTL, report)
427
- return report
428
-
429
-
430
- def _permission_snapshot() -> Dict[str, str]:
431
- """A copy of the permission map: a caller must not be able to edit our state."""
432
- with _PERMISSION_LOCK:
433
- return {name: _PERMISSIONS.get(name, "unknown") for name in sorted(_PERMISSIONS)}
434
-
435
-
436
- def _command_exists(name: str) -> bool:
437
- try:
438
- return bool(_which(name))
439
- except Exception:
440
- return False
441
-
442
-
443
- def _wake_state() -> Dict[str, Any]:
444
- """The in-process half of the wake-lock answer (``capabilities`` needs it)."""
445
- available = _command_exists(_ACQUIRE)
446
- with _WAKE_LOCK:
447
- return {"command": available,
448
- # None while the command is missing: a phone without the API
449
- # cannot tell us whether a lock is held, and guessing would be
450
- # worse than saying "unknown".
451
- "held": (bool(_WAKE_COUNT > 0) if available else None),
452
- "count": int(_WAKE_COUNT)}
453
-
454
-
455
- def _record_app_timeout(command: str) -> None:
456
- """Remember that a ``termux-*`` call hung, which points at the app, not the package.
457
-
458
- The cached capabilities block is dropped with it: a reader that still sees
459
- ``app_reachable: True`` (or "unknown") would hide the observation that was just
460
- made, and telling the operator once is the whole point of remembering it.
461
- """
462
- global _APP_TIMEOUT_AT, _APP_TIMEOUT_COMMAND
463
- with _CACHE_LOCK:
464
- _APP_TIMEOUT_COMMAND = str(command)
465
- _APP_TIMEOUT_AT = time.monotonic()
466
- _CACHE.pop("capabilities", None)
467
-
468
-
469
- def _record_app_success() -> None:
470
- """An answered call disproves every earlier timeout, so drop that evidence."""
471
- global _APP_SUCCESS, _APP_TIMEOUT_AT, _APP_TIMEOUT_COMMAND
472
- with _CACHE_LOCK:
473
- changed = (not _APP_SUCCESS) or bool(_APP_TIMEOUT_COMMAND)
474
- _APP_SUCCESS = True
475
- _APP_TIMEOUT_COMMAND = ""
476
- _APP_TIMEOUT_AT = 0.0
477
- if changed:
478
- # Only when the answer actually changes: a working phone must not
479
- # rebuild the capabilities block on every single successful call.
480
- _CACHE.pop("capabilities", None)
481
-
482
-
483
- def _app_reachable() -> Optional[bool]:
484
- """Is the Termux:API app answering? ``None`` means "no evidence either way".
485
-
486
- A fresh node must not claim the app is broken, so the answer only becomes
487
- ``False`` when the newest evidence is a *recent* hang. A record older than
488
- :data:`APP_EVIDENCE_TTL` is no evidence at all, because the operator may have
489
- installed or opened the app in the meantime.
490
- """
491
- now = time.monotonic()
492
- with _CACHE_LOCK:
493
- succeeded = _APP_SUCCESS
494
- hung = bool(_APP_TIMEOUT_COMMAND)
495
- hung_at = _APP_TIMEOUT_AT
496
- if hung and (now - hung_at) <= APP_EVIDENCE_TTL:
497
- return False
498
- if succeeded:
499
- return True
500
- return None
501
-
502
-
503
- def capabilities(refresh: bool = False) -> Dict[str, Any]:
504
- """A mergeable block for :mod:`core.device`'s capabilities section.
505
-
506
- It answers what a model needs before it tries something: is the API there,
507
- can the node reach shared storage, is a wake lock held, which permission a
508
- *previous* call proved to be missing, and - the one shared cause behind a
509
- phone whose every command hangs - whether the Termux:API **app** is
510
- answering at all (``app_reachable``: ``True``/``False``/``None`` for "no
511
- evidence", with ``app_fix`` present only when it is ``False``). Permission
512
- states are reported, never probed - opening the camera to learn whether the
513
- camera is allowed would be an unwanted side effect of a status call.
514
- """
515
- now = time.monotonic()
516
- if not refresh:
517
- with _CACHE_LOCK:
518
- hit = _CACHE.get("capabilities")
519
- if hit and hit[0] > now:
520
- return hit[1]
521
- value = _build_capabilities(refresh)
522
- with _CACHE_LOCK:
523
- _CACHE["capabilities"] = (now + _CACHE_TTL, value)
524
- return value
525
-
526
-
527
- def _build_capabilities(refresh: bool) -> Dict[str, Any]:
528
- fixes: List[str] = []
529
- try:
530
- api = api_installed(refresh=refresh)
531
- except Exception as exc:
532
- api = {"installed": False, "commands": {}, "missing": list(REQUIRED_COMMANDS),
533
- "fix": TERMUX_API_FIX, "source": "shutil.which",
534
- "error": "%s: %s" % (type(exc).__name__, exc)}
535
- if not api.get("installed"):
536
- fixes.append(TERMUX_API_FIX)
537
-
538
- try:
539
- storage_permission = bool(os.path.isdir(os.path.expanduser("~/storage/shared")))
540
- except Exception:
541
- storage_permission = False
542
- if not storage_permission:
543
- fixes.append(TERMUX_STORAGE_FIX)
544
-
545
- wake = _wake_state()
546
- if wake.get("command") is not True:
547
- fixes.append(TERMUX_API_FIX)
548
-
549
- permissions = _permission_snapshot()
550
- for api_name in permissions:
551
- if permissions[api_name] == "denied":
552
- fixes.append(_permission_fix(api_name))
553
-
554
- unique: List[str] = []
555
- for fix in fixes:
556
- if fix and fix not in unique:
557
- unique.append(fix)
558
- app_reachable = _app_reachable()
559
- block: Dict[str, Any] = {
560
- "api": api,
561
- "storage_permission": storage_permission,
562
- "wake_lock": wake,
563
- "permissions": permissions,
564
- "fixes": unique,
565
- "app_reachable": app_reachable,
566
- }
567
- if app_reachable is False:
568
- # Deliberately a key of its own rather than another entry in "fixes": the
569
- # timeout fix is about the app, and a consumer merging this block into a
570
- # device report has to be able to tell the two kinds of fix apart.
571
- block["app_fix"] = TERMUX_TIMEOUT_FIX
572
- return block
573
-
574
-
575
- # ---------------------------------------------------------------------------
576
- # Running one command
577
- # ---------------------------------------------------------------------------
578
-
579
- def _elapsed_ms(started: float) -> int:
580
- return int(round((time.monotonic() - started) * 1000.0))
581
-
582
-
583
- def _failure(command: str, argv: Sequence[str], started: float, reason: str,
584
- **fields: Any) -> Dict[str, Any]:
585
- result: Dict[str, Any] = {
586
- "ok": False,
587
- "reason": reason,
588
- "command": command,
589
- "argv": list(argv),
590
- "duration_ms": _elapsed_ms(started),
591
- }
592
- result.update(fields)
593
- return result
594
-
595
-
596
- def _allowed(name: str) -> bool:
597
- """The security guard: only bare ``termux-*`` command names may be executed.
598
-
599
- A caller must never be able to turn a tool argument into an arbitrary
600
- execution, so the prefix is checked *and* a path separator is refused -
601
- ``termux-../../bin/sh`` satisfies the prefix while escaping the allowlist.
602
- """
603
- if not name.startswith("termux-"):
604
- return False
605
- return not any(separator in name for separator in ("/", "\\"))
606
-
607
-
608
- def _bounded(timeout: Any) -> float:
609
- """A timeout that is always usable, whatever the caller passed."""
610
- try:
611
- return max(0.2, float(timeout))
612
- except Exception:
613
- return DEFAULT_TIMEOUT
614
-
615
-
616
- def _seconds(value: Any) -> float:
617
- try:
618
- return float(value)
619
- except Exception:
620
- return 0.0
621
-
622
-
623
- def run_json(command: str, args: Sequence[str] = (),
624
- timeout: float = DEFAULT_TIMEOUT) -> Dict[str, Any]:
625
- """Run one ``termux-*`` command and return its JSON answer as ``value``.
626
-
627
- Non-JSON stdout is **not** an error: several commands (``termux-open``,
628
- ``termux-toast``) answer with a sentence or with nothing at all, and a model
629
- is better served by the text plus ``parsed: False`` than by a failure.
630
- """
631
- return _execute(command, args, timeout, parse=True)
632
-
633
-
634
- def run_text(command: str, args: Sequence[str] = (),
635
- timeout: float = DEFAULT_TIMEOUT) -> Dict[str, Any]:
636
- """Run one ``termux-*`` command and return its stdout as ``value``."""
637
- return _execute(command, args, timeout, parse=False)
638
-
639
-
640
- def _execute(command: str, args: Sequence[str], timeout: float, parse: bool) -> Dict[str, Any]:
641
- started = time.monotonic()
642
- name = str(command or "")
643
- if isinstance(args, (str, bytes)):
644
- # A bare string is one argument, never a split on spaces: splitting is
645
- # exactly the step that would turn caller text into extra options.
646
- extra = [args if isinstance(args, str) else _decode(args)]
647
- else:
648
- try:
649
- extra = [str(item) for item in (args or ())]
650
- except Exception:
651
- extra = []
652
- argv = [name] + extra
653
-
654
- if not _allowed(name):
655
- return _failure(name, argv, started,
656
- "refused: %r is not a termux-* command" % name)
657
-
658
- if not _command_exists(name):
659
- # Nothing is executed when the command is absent: the failure itself is
660
- # the answer, and running a "help" invocation would just be noise.
661
- return _failure(name, argv, started,
662
- "the %s command is not installed" % name,
663
- missing=True, fix=TERMUX_API_FIX)
664
-
665
- try:
666
- rc, text = _run(argv, _bounded(timeout))
667
- except subprocess.TimeoutExpired as exc:
668
- # One hang is evidence about the app, not about this command: record it so
669
- # the next capabilities() call can explain the whole phone at once.
670
- _record_app_timeout(name)
671
- fields: Dict[str, Any] = {"timeout": True, "fix": TERMUX_TIMEOUT_FIX}
672
- partial = _decode(getattr(exc, "stdout", None) or getattr(exc, "stderr", None))
673
- if partial:
674
- fields["raw"] = partial
675
- seconds = _seconds(getattr(exc, "timeout", None)) or _seconds(timeout)
676
- return _failure(name, argv, started,
677
- "%s timed out after %gs" % (name, seconds), **fields)
678
- except Exception as exc:
679
- return _failure(name, argv, started,
680
- "%s could not be run: %s" % (name, exc))
681
-
682
- # The seam may hand back text or bytes; normalising here keeps the contract of
683
- # the result dict the same whichever way it arrived.
684
- raw = _decode(text)
685
- if rc != 0:
686
- reason = raw.strip() or "%s exited with %d" % (name, rc)
687
- fields = _diagnose(raw, name)
688
- fields["raw"] = raw
689
- return _failure(name, argv, started, reason, **fields)
690
-
691
- value: Any = raw
692
- extra_fields: Dict[str, Any] = {}
693
- if parse:
694
- try:
695
- value = json.loads(raw)
696
- except Exception:
697
- value = raw
698
- extra_fields["parsed"] = False
699
- # A refused permission arrives as a SUCCESSFUL call: the app prints
700
- # {"error": "Please grant the following permission to use this command: ..."}
701
- # on stdout and exits 0. Reading only the exit code therefore turned "Android
702
- # said no" into an empty answer - the model saw a phone with no cell data
703
- # instead of a phone that needs one permission granted.
704
- if isinstance(value, dict):
705
- message = value.get("error")
706
- if isinstance(message, str) and message.strip():
707
- fields = _diagnose(message, name)
708
- fields["raw"] = raw
709
- return _failure(name, argv, started, message.strip(), **fields)
710
- # Only an answered call counts. A non-zero exit is not proof that the app is
711
- # alive - the honest reading of "the app is unreachable" is the one that
712
- # requires positive evidence before it is cleared.
713
- _record_app_success()
714
- result: Dict[str, Any] = {
715
- "ok": True,
716
- "value": value,
717
- "raw": raw,
718
- "command": name,
719
- "argv": argv,
720
- "duration_ms": _elapsed_ms(started),
721
- }
722
- result.update(extra_fields)
723
- return result
724
-
725
-
726
- def sensor_sample(name: str, timeout: float = 8.0) -> Dict[str, Any]:
727
- """One value from one named sensor.
728
-
729
- ``-n 1`` is built here and can never come from the caller: without it
730
- ``termux-sensor`` streams samples until it is killed, so a model asking for a
731
- single reading would hold the tool open until its own timeout expires.
732
- """
733
- sensor = str(name or "").strip()
734
- return run_json("termux-sensor", ["-s", sensor, "-n", "1"], timeout)
735
-
736
-
737
- # ---------------------------------------------------------------------------
738
- # Failure text -> a fix and a remembered permission
739
- # ---------------------------------------------------------------------------
740
-
741
- def _permission_fix(api: str) -> str:
742
- return ("grant the %s permission to Termux:API in Android Settings > Apps > "
743
- "Termux:API > Permissions" % api)
744
-
745
-
746
- def _api_name(api: str) -> str:
747
- """Normalise a permission name, accepting a command name as a shorthand."""
748
- name = str(api or "").strip().lower()
749
- if not name:
750
- return ""
751
- return _COMMAND_API.get(name, name)
752
-
753
-
754
- def record_permission_state(api: str, state: str) -> None:
755
- """Remember what a real call proved about one Android permission.
756
-
757
- Only an observed call may call this: asking Android up front would mean
758
- opening the camera or the microphone to read a state, which is the side
759
- effect this module refuses to have. The cached capabilities block is dropped
760
- with it, so the next reader sees the new fact instead of a stale "unknown".
761
- """
762
- name = _api_name(api)
763
- wanted = str(state or "").strip().lower()
764
- if not name or wanted not in ("granted", "denied", "unknown"):
765
- return
766
- with _PERMISSION_LOCK:
767
- _PERMISSIONS[name] = wanted
768
- with _CACHE_LOCK:
769
- _CACHE.pop("capabilities", None)
770
-
771
-
772
- def _diagnose(text: str, command: str = "") -> Dict[str, Any]:
773
- """Map a failing command's output to ``denied``/``fix`` and remember it.
774
-
775
- All of the "Android said no" vocabulary lives in this one function, so a new
776
- permission-backed command only has to be added to ``_COMMAND_API`` to be
777
- explained properly. An empty dict means "no permission story here" and lets
778
- the caller report the raw text unchanged.
779
- """
780
- lowered = (text or "").lower()
781
- if not lowered:
782
- return {}
783
- denied = any(phrase in lowered for phrase in _PERMISSION_PHRASES)
784
- if not denied and "camera" in lowered:
785
- denied = any(word in lowered for word in _PERMISSION_CONTEXT)
786
- if not denied and any(word in lowered for word in ("microphone", "audio record", "recording")):
787
- denied = any(word in lowered for word in _PERMISSION_CONTEXT)
788
- if not denied:
789
- return {}
790
- # The text is the more specific evidence, so it is read first: a command that
791
- # names a permission in its own message is believed over the command map.
792
- api = ""
793
- if "camera" in lowered or "/dev/video" in lowered:
794
- api = "camera"
795
- elif any(word in lowered for word in ("microphone", "audio record", "recording")):
796
- api = "microphone"
797
- elif "location" in lowered:
798
- api = "location"
799
- if not api:
800
- api = _COMMAND_API.get(str(command or ""), "")
801
- fields: Dict[str, Any] = {"denied": True, "fix": _permission_fix(api or "requested")}
802
- if api:
803
- record_permission_state(api, "denied")
804
- return fields
805
-
806
-
807
- # ---------------------------------------------------------------------------
808
- # Wake lock
809
- # ---------------------------------------------------------------------------
810
-
811
- def _wake_report(command: str, available: bool, reason: str = "",
812
- **fields: Any) -> Dict[str, Any]:
813
- report: Dict[str, Any] = {
814
- "available": bool(available),
815
- # ``held`` is None while the command is missing: the honest answer is
816
- # "unknown", because a phone without the API cannot tell us either way.
817
- "held": (bool(_WAKE_COUNT > 0) if available else None),
818
- "count": int(_WAKE_COUNT),
819
- "command": command,
820
- }
821
- if reason:
822
- report["reason"] = reason
823
- report.update(fields)
824
- return report
825
-
826
-
827
- def wake_lock(action: str = "status") -> Dict[str, Any]:
828
- """Take, drop or report the Android wake lock, reference counted in process.
829
-
830
- Android freezes a background process once the screen goes off, and the node's
831
- install-time boot script takes the lock exactly once with nobody left to
832
- release it. Counting the lock here means the system command runs only when the
833
- lock really changes state - once on the way up (0 -> 1) and once on the way
834
- down (1 -> 0) - so ``acquire`` twice and ``release`` twice still touch Android
835
- once each, and the count can never go below zero. There is no "is a lock
836
- held?" command in Termux:API, so ``held`` describes *this process's* count -
837
- the only truth available without root.
838
- """
839
- global _WAKE_COUNT
840
- wanted = str(action or "status").strip().lower()
841
- if wanted not in _WAKE_ACTIONS and wanted != "status":
842
- with _WAKE_LOCK:
843
- count = _WAKE_COUNT
844
- return {"available": False, "held": None, "count": int(count), "command": "",
845
- "reason": "refused: %r is not a wake-lock action" % (str(action),)}
846
-
847
- with _WAKE_LOCK:
848
- if wanted == "status":
849
- # No status command exists, so nothing may be executed here - running
850
- # the lock command to "check" would change the state it reports.
851
- return _wake_report(_ACQUIRE, _command_exists(_ACQUIRE))
852
-
853
- command = _WAKE_ACTIONS[wanted]
854
- if wanted == "acquire" and _WAKE_COUNT > 0:
855
- # Already held by this process: Android counts the lock, not the calls,
856
- # so a second acquire only moves our own counter.
857
- _WAKE_COUNT += 1
858
- return _wake_report(command, True)
859
- if wanted == "release":
860
- if _WAKE_COUNT == 0:
861
- # Nothing of ours is held: unlocking now would drop a lock some
862
- # other app took, and the ref-count is the only thing that can know.
863
- return _wake_report(command, _command_exists(command))
864
- if _WAKE_COUNT > 1:
865
- # Other holders remain, so the system lock must stay taken.
866
- _WAKE_COUNT -= 1
867
- return _wake_report(command, True)
868
-
869
- if not _command_exists(command):
870
- return _wake_report(command, False,
871
- "the %s command is not installed" % command,
872
- missing=True, fix=TERMUX_API_FIX)
873
-
874
- try:
875
- rc, text = _run([command], WAKE_LOCK_TIMEOUT)
876
- except subprocess.TimeoutExpired as exc:
877
- seconds = _seconds(getattr(exc, "timeout", None)) or WAKE_LOCK_TIMEOUT
878
- return _wake_report(command, True,
879
- "%s timed out after %gs" % (command, seconds),
880
- timeout=True, fix=TERMUX_TIMEOUT_FIX)
881
- except Exception as exc:
882
- return _wake_report(command, True, "%s could not be run: %s" % (command, exc))
883
-
884
- if rc != 0:
885
- fields = _diagnose(text, command)
886
- reason = (text or "").strip() or "%s exited with %d" % (command, rc)
887
- return _wake_report(command, True, reason, **fields)
888
-
889
- if wanted == "acquire":
890
- _WAKE_COUNT += 1
891
- else:
892
- # Only a command that really ran gives the count back; a failed unlock
893
- # leaves the lock held, which is what the phone still believes.
894
- _WAKE_COUNT = max(0, _WAKE_COUNT - 1)
895
- return _wake_report(command, True)
896
-
897
-
898
- # ---------------------------------------------------------------------------
899
- # Test seam
900
- # ---------------------------------------------------------------------------
901
-
902
- def reset_state() -> None:
903
- """Drop every in-process cache: the inventory, capabilities, the lock count,
904
- the permission map and the app evidence, as if this module had just been
905
- imported."""
906
- global _WAKE_COUNT, _APP_SUCCESS, _APP_TIMEOUT_AT, _APP_TIMEOUT_COMMAND
907
- with _CACHE_LOCK:
908
- _CACHE.clear()
909
- _APP_SUCCESS = False
910
- _APP_TIMEOUT_COMMAND = ""
911
- _APP_TIMEOUT_AT = 0.0
912
- with _PERMISSION_LOCK:
913
- _PERMISSIONS.clear()
914
- _PERMISSIONS.update({name: "unknown" for name in PERMISSION_APIS})
915
- with _WAKE_LOCK:
916
- _WAKE_COUNT = 0