gemini-computer-use 0.2.4 → 0.4.2

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/install.sh CHANGED
@@ -2,6 +2,7 @@
2
2
  # ==============================================================================
3
3
  # Antigravity Mesh - Universal Turnkey Installer (v0.1.2)
4
4
  # Bilingual: English (Default) & Russian, Device Detection, SSH & Autostart
5
+ # Targets: Linux, macOS, Windows (Git Bash) and Android/Termux (phone/tablet)
5
6
  # ==============================================================================
6
7
  set -e
7
8
 
@@ -10,7 +11,7 @@ MODE=""
10
11
  TLS="none"
11
12
  PORT="8096"
12
13
  DOMAIN=""
13
- GATEWAY="smart-server.online"
14
+ GATEWAY=""
14
15
  USERNAME=""
15
16
  TOKEN=""
16
17
  DRY_RUN=false
@@ -23,18 +24,267 @@ EXPLICIT_LANG=""
23
24
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
24
25
  [ -z "$SCRIPT_DIR" ] && SCRIPT_DIR="."
25
26
 
26
- # Bootstrap if piped from curl or run outside project
27
- if [ ! -f "$SCRIPT_DIR/core/agent.py" ]; then
28
- BOOTSTRAP_DIR="$HOME/.gemini-computer-use"
29
- mkdir -p "$BOOTSTRAP_DIR/core" "$BOOTSTRAP_DIR/skills"
30
- curl -fsSL "https://${GATEWAY}/core/agent.py" -o "$BOOTSTRAP_DIR/core/agent.py" 2>/dev/null || curl -fsSL "https://raw.githubusercontent.com/LevRa7/Computer-use-for-Gemini-App-Web/main/core/agent.py" -o "$BOOTSTRAP_DIR/core/agent.py" 2>/dev/null || true
31
- curl -fsSL "https://${GATEWAY}/core/server.py" -o "$BOOTSTRAP_DIR/core/server.py" 2>/dev/null || curl -fsSL "https://raw.githubusercontent.com/LevRa7/Computer-use-for-Gemini-App-Web/main/core/server.py" -o "$BOOTSTRAP_DIR/core/server.py" 2>/dev/null || true
32
- curl -fsSL "https://${GATEWAY}/core/mcp_tools.py" -o "$BOOTSTRAP_DIR/core/mcp_tools.py" 2>/dev/null || curl -fsSL "https://raw.githubusercontent.com/LevRa7/Computer-use-for-Gemini-App-Web/main/core/mcp_tools.py" -o "$BOOTSTRAP_DIR/core/mcp_tools.py" 2>/dev/null || true
33
- curl -fsSL "https://${GATEWAY}/core/vitals.py" -o "$BOOTSTRAP_DIR/core/vitals.py" 2>/dev/null || curl -fsSL "https://raw.githubusercontent.com/LevRa7/Computer-use-for-Gemini-App-Web/main/core/vitals.py" -o "$BOOTSTRAP_DIR/core/vitals.py" 2>/dev/null || true
34
- curl -fsSL "https://${GATEWAY}/core/__init__.py" -o "$BOOTSTRAP_DIR/core/__init__.py" 2>/dev/null || true
35
- curl -fsSL "https://${GATEWAY}/skills/orchestrator.md" -o "$BOOTSTRAP_DIR/skills/orchestrator.md" 2>/dev/null || true
36
- SCRIPT_DIR="$BOOTSTRAP_DIR"
27
+ # ------------------------------------------------------------------------------
28
+ # PUBLIC DOMAIN - resolved from the one source of truth
29
+ #
30
+ # The domain is declared exactly once, in core/domain.py. Everything below asks
31
+ # that same chain, in the same order, and this is the ONLY place in this script
32
+ # that picks a hostname:
33
+ #
34
+ # 1. an explicit --domain= / --gateway= on the command line;
35
+ # 2. MESH_PUBLIC_URL in the environment;
36
+ # 3. AGY_PUBLIC_BASE_URL in the environment (legacy alias, same meaning);
37
+ # 4. the domain file - MESH_DOMAIN_FILE when set, otherwise
38
+ # /etc/antigravity-mesh/domain.env, or on Windows/Git Bash
39
+ # %USERPROFILE%\.config\antigravity-mesh\domain.env;
40
+ # 5. $PUBLISHED_DOMAIN - the domain this copy was published with. The repository
41
+ # carries the placeholder __MESH_DOMAIN__ here and deploy_gateway.sh rewrites
42
+ # that line while publishing, so a served copy already knows its domain;
43
+ # 6. core/domain.py's DEFAULT_PUBLIC_BASE_URL, but only when that module sits
44
+ # next to this script (a repository checkout, or a node that already
45
+ # bootstrapped). A clone has no published value and refusing to install from
46
+ # a clone would be wrong.
47
+ #
48
+ # If none of them yields a real hostname the installer stops: registering a node
49
+ # against a domain that does not resolve produces a node that can never connect
50
+ # and an autostart entry that retries forever.
51
+ # ------------------------------------------------------------------------------
52
+ DOMAIN_PLACEHOLDER="__MESH_DOMAIN__"
53
+ # deploy_gateway.sh rewrites THIS line only (it is anchored on PUBLISHED_DOMAIN=),
54
+ # so the placeholder token above survives and still proves whether this copy was
55
+ # published. In the repository the two are equal, which means "not published".
56
+ PUBLISHED_DOMAIN="__MESH_DOMAIN__"
57
+
58
+ # ------------------------------------------------------------------------------
59
+ # TERMUX / ANDROID
60
+ #
61
+ # Termux is NOT FHS-compliant: there is no /etc, no /bin and no /usr at the
62
+ # usual locations, there is no root and no sudo, and its packages are built
63
+ # against Android's bionic libc. A phone is therefore neither "just Linux" (the
64
+ # Debian package names and the host-level domain file do not exist) nor
65
+ # something the Linux branches can serve unchanged. Everything Termux-specific
66
+ # in this script keys off the single flag below:
67
+ #
68
+ # IS_TERMUX - the one question every branch asks;
69
+ # TERMUX_PREFIX - $PREFIX, the Termux root (.../com.termux/files/usr);
70
+ # TERMUX_SUPERVISED - set by configure_termux_node when runit supervises.
71
+ #
72
+ # Detection never trusts one signal: Termux exports TERMUX_VERSION, its $PREFIX
73
+ # points into com.termux, and its application data directory has a fixed path.
74
+ # A Linux host that happens to export an unrelated $PREFIX is not a phone.
75
+ # ------------------------------------------------------------------------------
76
+ IS_TERMUX=false
77
+ if [ -n "${TERMUX_VERSION:-}" ]; then
78
+ IS_TERMUX=true
37
79
  fi
80
+ if [ "$IS_TERMUX" = false ] && [ -n "${PREFIX:-}" ]; then
81
+ case "$PREFIX" in
82
+ *com.termux*) IS_TERMUX=true ;;
83
+ esac
84
+ fi
85
+ if [ "$IS_TERMUX" = false ] && [ -d /data/data/com.termux/files/usr/bin ]; then
86
+ IS_TERMUX=true
87
+ fi
88
+
89
+ TERMUX_PREFIX=""
90
+ TERMUX_SUPERVISED=false
91
+ if [ "$IS_TERMUX" = true ]; then
92
+ TERMUX_PREFIX="${PREFIX:-}"
93
+ if [ -z "$TERMUX_PREFIX" ] || [ ! -d "$TERMUX_PREFIX/bin" ]; then
94
+ TERMUX_PREFIX="/data/data/com.termux/files/usr"
95
+ fi
96
+ fi
97
+
98
+ case "$(uname -s 2>/dev/null)" in
99
+ MINGW*|MSYS*|CYGWIN*) DEFAULT_DOMAIN_DIR="${USERPROFILE:-$HOME}/.config/antigravity-mesh" ;;
100
+ *) DEFAULT_DOMAIN_DIR="/etc/antigravity-mesh" ;;
101
+ esac
102
+ # A phone has no /etc and no root, so the per-user directory is the only place
103
+ # that can hold the domain file there. core/domain.py answers the same question
104
+ # in default_domain_file(), so the installer and the node never disagree about
105
+ # where the domain lives.
106
+ if [ "$IS_TERMUX" = true ]; then
107
+ DEFAULT_DOMAIN_DIR="$HOME/.config/antigravity-mesh"
108
+ fi
109
+ DEFAULT_DOMAIN_FILE="${MESH_DOMAIN_FILE:-$DEFAULT_DOMAIN_DIR/domain.env}"
110
+
111
+ # The command that stores the domain, spelled for the platform it is printed on:
112
+ # there is no sudo in Termux and no writable /etc, so the hint must not send a
113
+ # phone user to a command that cannot exist there.
114
+ domain_file_write_cmd() {
115
+ if [ "$IS_TERMUX" = true ]; then
116
+ printf "mkdir -p %s && echo 'MESH_PUBLIC_URL=%s' > %s" \
117
+ "$DEFAULT_DOMAIN_DIR" "$1" "$DEFAULT_DOMAIN_FILE"
118
+ else
119
+ printf "echo 'MESH_PUBLIC_URL=%s' | sudo tee %s" "$1" "$DEFAULT_DOMAIN_FILE"
120
+ fi
121
+ }
122
+
123
+ # https://mesh.example.com/path/ -> mesh.example.com (the authority only)
124
+ normalise_host() {
125
+ printf '%s' "$1" | sed -e 's|^[A-Za-z][A-Za-z0-9+.-]*://||' -e 's|/.*$||' \
126
+ -e 's|^[[:space:]]*||' -e 's|[[:space:]]*$||'
127
+ }
128
+
129
+ # A KEY=VALUE file without its leading UTF-8 BOM (Windows PowerShell writes one).
130
+ strip_bom() {
131
+ if [ "$(head -c 3 "$1" 2>/dev/null | od -An -tx1 | tr -d ' \n')" = "efbbbf" ]; then
132
+ tail -c +4 "$1"
133
+ else
134
+ cat "$1"
135
+ fi
136
+ }
137
+
138
+ # MESH_PUBLIC_URL outranks the legacy alias, whatever their order in the file.
139
+ domain_file_value() {
140
+ local file="$1" value=""
141
+ if [ ! -f "$file" ]; then
142
+ return 0
143
+ fi
144
+ value="$(strip_bom "$file" | sed -n 's/^[[:space:]]*MESH_PUBLIC_URL[[:space:]]*=[[:space:]]*//p' | head -n 1)"
145
+ if [ -z "$value" ]; then
146
+ value="$(strip_bom "$file" | sed -n 's/^[[:space:]]*AGY_PUBLIC_BASE_URL[[:space:]]*=[[:space:]]*//p' | head -n 1)"
147
+ fi
148
+ printf '%s' "$value" | tr -d '"' | tr -d "'" | tr -d '\r' | sed -e 's|^[[:space:]]*||' -e 's|[[:space:]]*$||'
149
+ }
150
+
151
+ # The project default, taken from core/domain.py when that file is available here.
152
+ module_default_domain() {
153
+ local module="$SCRIPT_DIR/core/domain.py" value=""
154
+ if [ ! -f "$module" ]; then
155
+ return 0
156
+ fi
157
+ if command -v python3 >/dev/null 2>&1; then
158
+ value="$(cd "$SCRIPT_DIR" 2>/dev/null && python3 -c 'from core import domain; print(domain.DEFAULT_PUBLIC_BASE_URL)' 2>/dev/null)" || true
159
+ fi
160
+ if [ -z "$value" ]; then
161
+ value="$(sed -n 's/^DEFAULT_PUBLIC_BASE_URL[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$module" | head -n 1)"
162
+ fi
163
+ printf '%s' "$value"
164
+ }
165
+
166
+ resolve_domain() {
167
+ local explicit_value="${1:-}" candidate=""
168
+ if [ -n "$explicit_value" ]; then
169
+ printf '%s' "$(normalise_host "$explicit_value")"
170
+ return 0
171
+ fi
172
+ for candidate in "${MESH_PUBLIC_URL:-}" "${AGY_PUBLIC_BASE_URL:-}"; do
173
+ if [ -n "$candidate" ]; then
174
+ printf '%s' "$(normalise_host "$candidate")"
175
+ return 0
176
+ fi
177
+ done
178
+ candidate="$(domain_file_value "$DEFAULT_DOMAIN_FILE")"
179
+ if [ -n "$candidate" ]; then
180
+ printf '%s' "$(normalise_host "$candidate")"
181
+ return 0
182
+ fi
183
+ if [ -n "$PUBLISHED_DOMAIN" ] && [ "$PUBLISHED_DOMAIN" != "$DOMAIN_PLACEHOLDER" ]; then
184
+ printf '%s' "$(normalise_host "$PUBLISHED_DOMAIN")"
185
+ return 0
186
+ fi
187
+ candidate="$(module_default_domain)"
188
+ if [ -n "$candidate" ]; then
189
+ printf '%s' "$(normalise_host "$candidate")"
190
+ return 0
191
+ fi
192
+ printf '%s' "$DOMAIN_PLACEHOLDER"
193
+ }
194
+
195
+ # ------------------------------------------------------------------------------
196
+ # PERSIST THE DOMAIN (what the node reads to build its public links)
197
+ #
198
+ # core/domain.py resolves the public domain from MESH_PUBLIC_URL in the
199
+ # environment, then from the domain file, then from its built-in default - and it
200
+ # never reads the legacy MESH_GATEWAY that agent.env carries. A node installed
201
+ # with only agent.env therefore dials the right gateway (gateway_host() does
202
+ # honour MESH_GATEWAY) while minting share links on the built-in default: a real
203
+ # Termux node published links on the default relay while its tunnel went to the
204
+ # gateway it was actually registered on.
205
+ #
206
+ # This script has already resolved the domain, so it writes it down here - the
207
+ # same thing deploy_gateway.sh does for /etc/antigravity-mesh/domain.env on the
208
+ # gateway host. The place that decides the domain is then also the place that
209
+ # records it, and the node's links, its tunnel and the gateway agree by
210
+ # construction.
211
+ #
212
+ # Never fatal: an unwritable /etc (no sudo, a locked-down box) still leaves a
213
+ # working node - the legacy MESH_GATEWAY keeps the tunnel where it was - so a
214
+ # failure only prints the command the operator can run by hand.
215
+ # ------------------------------------------------------------------------------
216
+ write_domain_file() {
217
+ local domain existing content module_default parent
218
+ domain="$(normalise_host "${1:-}")"
219
+ if [ -z "$domain" ] || [ "$domain" = "$DOMAIN_PLACEHOLDER" ]; then
220
+ return 0
221
+ fi
222
+ # Never record the project's built-in default. It is a fallback, not a decision:
223
+ # writing it would turn "nothing is configured" into a pinned domain file, and
224
+ # once that file exists core/domain.py stops consulting the legacy MESH_GATEWAY
225
+ # at all - the operator's own agent.env knob would silently go inert. Nothing is
226
+ # lost by skipping it: with no file the resolver's last fallback IS that value,
227
+ # so the node's links and its tunnel still name the same host.
228
+ module_default="$(normalise_host "$(module_default_domain)")"
229
+ if [ -n "$module_default" ] && [ "$domain" = "$module_default" ]; then
230
+ return 0
231
+ fi
232
+ existing="$(normalise_host "$(domain_file_value "$DEFAULT_DOMAIN_FILE")")"
233
+ if [ "$existing" = "$domain" ]; then
234
+ return 0
235
+ fi
236
+ content="MESH_PUBLIC_URL=https://${domain}"
237
+ # The parent of the file that is actually used, not of the default path: with
238
+ # MESH_DOMAIN_FILE pointing into a directory that does not exist yet, the old
239
+ # code created a directory nobody would read and then failed to write.
240
+ parent="$(dirname "$DEFAULT_DOMAIN_FILE")"
241
+
242
+ if mkdir -p "$parent" 2>/dev/null \
243
+ && printf '%s\n' "$content" > "$DEFAULT_DOMAIN_FILE" 2>/dev/null; then
244
+ :
245
+ elif run_privileged mkdir -p "$parent" >/dev/null 2>&1 \
246
+ && printf '%s\n' "$content" | run_privileged tee "$DEFAULT_DOMAIN_FILE" >/dev/null 2>&1; then
247
+ :
248
+ else
249
+ if [ "$LANG_CHOICE" = "ru" ]; then
250
+ echo -e "${YELLOW}[!] Не удалось записать домен в ${DEFAULT_DOMAIN_FILE}.${RESET}"
251
+ echo -e " Ссылки узла будут строиться на встроенном домене по умолчанию."
252
+ echo -e " Запишите вручную: ${CYAN}$(domain_file_write_cmd "https://${domain}")${RESET}"
253
+ else
254
+ echo -e "${YELLOW}[!] Could not write the domain to ${DEFAULT_DOMAIN_FILE}.${RESET}"
255
+ echo -e " The node's links would use the built-in default domain."
256
+ echo -e " Write it by hand: ${CYAN}$(domain_file_write_cmd "https://${domain}")${RESET}"
257
+ fi
258
+ return 0
259
+ fi
260
+
261
+ if [ "$LANG_CHOICE" = "ru" ]; then
262
+ if [ -n "$existing" ]; then
263
+ echo -e "${GREEN}[OK] Домен узла: ${content} (${DEFAULT_DOMAIN_FILE}, было https://${existing})${RESET}"
264
+ else
265
+ echo -e "${GREEN}[OK] Домен узла: ${content} (${DEFAULT_DOMAIN_FILE})${RESET}"
266
+ fi
267
+ else
268
+ if [ -n "$existing" ]; then
269
+ echo -e "${GREEN}[OK] Node domain: ${content} (${DEFAULT_DOMAIN_FILE}, was https://${existing})${RESET}"
270
+ else
271
+ echo -e "${GREEN}[OK] Node domain: ${content} (${DEFAULT_DOMAIN_FILE})${RESET}"
272
+ fi
273
+ fi
274
+ return 0
275
+ }
276
+
277
+ # Downloading the node code needs the domain, and so does every cloud-gateway
278
+ # mode. A standalone install that already has the code next to it needs neither.
279
+ domain_required() {
280
+ if [ ! -f "$SCRIPT_DIR/core/agent.py" ]; then
281
+ return 0
282
+ fi
283
+ if [ "$MODE" != "standalone" ]; then
284
+ return 0
285
+ fi
286
+ return 1
287
+ }
38
288
 
39
289
  CONFIG_DIR="$HOME/.config/antigravity-mesh"
40
290
  CONFIG_FILE="$CONFIG_DIR/agent.env"
@@ -48,48 +298,395 @@ BLUE="\033[0;34m"
48
298
  MAGENTA="\033[0;35m"
49
299
  RESET="\033[0m"
50
300
 
51
- ensure_python() {
52
- if command -v python3 >/dev/null 2>&1; then
301
+ # ------------------------------------------------------------------------------
302
+ # PYTHON AND ITS DEPENDENCIES
303
+ #
304
+ # websockets is the node's ONLY external requirement: core/mcp_tools.py and
305
+ # core/server.py are standard library only. Everything below exists because a
306
+ # fresh or minimal system can fail to provide it for a different reason each time:
307
+ #
308
+ # * no Python at all -> install it from the system package manager;
309
+ # * Python but no pip -> ensurepip, or uv, which needs no pip;
310
+ # * pip refuses to write -> PEP 668 "externally managed", or no permission
311
+ # on site-packages: --user, then a venv;
312
+ # * no usable package manager -> uv, which fetches its own CPython;
313
+ # * nothing works at all -> STOP, like the Windows installer does.
314
+ #
315
+ # The last point is the important one. This script used to continue with
316
+ # PYTHON_BIN=/usr/bin/python3 whether or not that file existed, wrote a systemd
317
+ # unit or a launchd plist naming it, and reported success. The node then never
318
+ # started and the only evidence was one log line - the exact silent failure the
319
+ # Windows installer already refuses to produce.
320
+ #
321
+ # PYTHON_BIN is the single interpreter everything afterwards must use, including
322
+ # the autostart unit. It is NOT necessarily "python3" from PATH: the venv and uv
323
+ # fallbacks both live outside PATH on purpose.
324
+ # ------------------------------------------------------------------------------
325
+ PYTHON_BIN=""
326
+ UV_BIN=""
327
+
328
+ # Is this a real, runnable Python 3? A name that answers to "command -v" is not
329
+ # evidence: a broken shim, a half-removed distro package and a stale symlink all
330
+ # pass that test and then fail every call.
331
+ python_works() {
332
+ [ -n "$1" ] || return 1
333
+ command -v "$1" >/dev/null 2>&1 || [ -x "$1" ] || return 1
334
+ "$1" -c 'import sys; raise SystemExit(0 if sys.version_info[0] == 3 else 1)' >/dev/null 2>&1
335
+ }
336
+
337
+ python_has_websockets() {
338
+ [ -n "$1" ] || return 1
339
+ "$1" -c 'import websockets' >/dev/null 2>&1
340
+ }
341
+
342
+ python_has_pip() {
343
+ [ -n "$1" ] || return 1
344
+ "$1" -m pip --version >/dev/null 2>&1
345
+ }
346
+
347
+ # Every call site after ensure_dependencies uses "$PYTHON_BIN"; this is what finds
348
+ # it. The uv-managed interpreter is searched too, because on a machine where uv is
349
+ # what provided Python there is no python3 on PATH at all.
350
+ resolve_python() {
351
+ local cand uv_root
352
+ for cand in python3 python; do
353
+ if python_works "$cand"; then
354
+ PYTHON_BIN="$(command -v "$cand")"
355
+ return 0
356
+ fi
357
+ done
358
+ uv_root="${UV_PYTHON_INSTALL_DIR:-$HOME/.local/share/uv/python}"
359
+ if [ -d "$uv_root" ]; then
360
+ for cand in "$uv_root"/cpython-3*/bin/python3; do
361
+ if [ -x "$cand" ] && python_works "$cand"; then
362
+ PYTHON_BIN="$cand"
363
+ return 0
364
+ fi
365
+ done
366
+ fi
367
+ return 1
368
+ }
369
+
370
+ # sudo when not already root, and nothing at all when neither is available. The old
371
+ # code spelled this out per branch and forgot it for apk, so `apk add` silently did
372
+ # nothing on a non-root Alpine - the one platform where Python is never preinstalled.
373
+ run_privileged() {
374
+ if [ "$(id -u)" -eq 0 ]; then
375
+ "$@"
376
+ elif command -v sudo >/dev/null 2>&1; then
377
+ sudo "$@"
378
+ else
379
+ return 1
380
+ fi
381
+ }
382
+
383
+ # One system-package install per platform, in a subshell with errexit off: a package
384
+ # manager that fails (no network, no sudo, a locked database, a dead mirror) must
385
+ # fall through to the next source instead of aborting the installer. Whether Python
386
+ # actually arrived is decided by resolve_python afterwards, never by these exit codes.
387
+ install_python_packages() {
388
+ (
389
+ set +e
390
+ if [ "$IS_TERMUX" = true ]; then
391
+ # Termux comes first: it HAS apt-get, but the Debian package names below
392
+ # (python3-pip, python3-venv) do not exist there, `pkg` is the wrapper
393
+ # that refreshes the index itself, and run_privileged cannot help -
394
+ # a phone is never root and has no sudo. pip and the venv wheels are
395
+ # separate packages, and python3 is a virtual name provided by python.
396
+ pkg install -y python python-pip python-ensurepip-wheels curl ca-certificates
397
+ elif command -v apt-get >/dev/null 2>&1; then
398
+ run_privileged apt-get update -qq
399
+ run_privileged apt-get install -y -qq python3 python3-pip python3-venv curl ca-certificates
400
+ elif command -v dnf >/dev/null 2>&1; then
401
+ run_privileged dnf install -y -q python3 python3-pip curl ca-certificates
402
+ elif command -v yum >/dev/null 2>&1; then
403
+ run_privileged yum install -y -q python3 python3-pip curl ca-certificates
404
+ elif command -v zypper >/dev/null 2>&1; then
405
+ run_privileged zypper --non-interactive install python3 python3-pip curl ca-certificates
406
+ elif command -v pacman >/dev/null 2>&1; then
407
+ run_privileged pacman -Sy --noconfirm python python-pip curl ca-certificates
408
+ elif command -v apk >/dev/null 2>&1; then
409
+ run_privileged apk add --no-cache python3 py3-pip curl ca-certificates
410
+ elif command -v xbps-install >/dev/null 2>&1; then
411
+ run_privileged xbps-install -Sy python3 python3-pip curl ca-certificates
412
+ elif [ "$(uname -s 2>/dev/null)" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
413
+ brew install python3
414
+ else
415
+ return 1
416
+ fi
53
417
  return 0
418
+ )
419
+ }
420
+
421
+ # curl is the script's transport, not a Python dependency: the bootstrap download,
422
+ # node registration and the standalone health check all go through it. A machine can
423
+ # have a perfectly good Python 3 and no curl at all (a minimal WSL image, a slim
424
+ # container, a phone where only python was installed), and install_python_packages()
425
+ # never runs in that case - it is called only when Python itself is missing. So the
426
+ # tool is asked for separately, BEFORE the step that needs it.
427
+ install_helper_packages() {
428
+ (
429
+ set +e
430
+ if [ "$IS_TERMUX" = true ]; then
431
+ pkg install -y curl ca-certificates
432
+ elif command -v apt-get >/dev/null 2>&1; then
433
+ run_privileged apt-get update -qq
434
+ run_privileged apt-get install -y -qq curl ca-certificates
435
+ elif command -v dnf >/dev/null 2>&1; then
436
+ run_privileged dnf install -y -q curl ca-certificates
437
+ elif command -v yum >/dev/null 2>&1; then
438
+ run_privileged yum install -y -q curl ca-certificates
439
+ elif command -v zypper >/dev/null 2>&1; then
440
+ run_privileged zypper --non-interactive install curl ca-certificates
441
+ elif command -v pacman >/dev/null 2>&1; then
442
+ run_privileged pacman -Sy --noconfirm curl ca-certificates
443
+ elif command -v apk >/dev/null 2>&1; then
444
+ run_privileged apk add --no-cache curl ca-certificates
445
+ elif command -v xbps-install >/dev/null 2>&1; then
446
+ run_privileged xbps-install -Sy curl ca-certificates
447
+ elif [ "$(uname -s 2>/dev/null)" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
448
+ brew install curl
449
+ else
450
+ return 1
451
+ fi
452
+ return 0
453
+ )
454
+ }
455
+
456
+ # True when curl is usable. Installs it if it is not, and fails closed when it cannot
457
+ # be obtained: without it the node can never be registered, and the alternative - the
458
+ # raw "curl: command not found" at the registration line - names neither the cause nor
459
+ # the fix. Called before the bootstrap download, before registration and before the
460
+ # standalone health check.
461
+ ensure_curl() {
462
+ command -v curl >/dev/null 2>&1 && return 0
463
+ if [ "$LANG_CHOICE" = "ru" ]; then
464
+ echo -e "\033[1;33m[!] curl не найден. Устанавливаю пакеты...\033[0m"
465
+ else
466
+ echo -e "\033[1;33m[!] curl not found. Installing it...\033[0m"
467
+ fi
468
+ install_helper_packages || true
469
+ command -v curl >/dev/null 2>&1 && return 0
470
+
471
+ if [ "$LANG_CHOICE" = "ru" ]; then
472
+ echo -e "\033[1;31m[!] Без curl установка невозможна.\033[0m" >&2
473
+ echo -e " Через него скачивается код узла, регистрируется узел и проверяется локальный порт." >&2
474
+ if [ "$IS_TERMUX" = true ]; then
475
+ echo -e " Установите вручную и повторите: ${CYAN}pkg install curl${RESET}" >&2
476
+ else
477
+ echo -e " Установите вручную и повторите: ${CYAN}sudo apt install curl${RESET} # или ваш менеджер пакетов" >&2
478
+ fi
479
+ echo -e " Остановка: без curl ничего не создано." >&2
480
+ else
481
+ echo -e "\033[1;31m[!] curl is required and could not be installed.\033[0m" >&2
482
+ echo -e " It downloads the node code, registers the node and checks the local port." >&2
483
+ if [ "$IS_TERMUX" = true ]; then
484
+ echo -e " Install it and re-run: ${CYAN}pkg install curl${RESET}" >&2
485
+ else
486
+ echo -e " Install it and re-run: ${CYAN}sudo apt install curl${RESET} # or your package manager" >&2
487
+ fi
488
+ echo -e " Stopping: nothing was created without it." >&2
489
+ fi
490
+ return 1
491
+ }
492
+
493
+ # uv is the last resort: one static binary that fetches its own CPython and installs
494
+ # packages without pip. The release archive is downloaded and unpacked here rather
495
+ # than piping astral.sh's install script into sh - a script that arrives over the
496
+ # network and is executed unread is a worse default than an archive this unpacks.
497
+ ensure_uv() {
498
+ if command -v uv >/dev/null 2>&1; then
499
+ UV_BIN="$(command -v uv)"
500
+ return 0
501
+ fi
502
+ if [ -x "$CONFIG_DIR/bin/uv" ]; then
503
+ UV_BIN="$CONFIG_DIR/bin/uv"
504
+ return 0
505
+ fi
506
+ # UV_TARGET is empty on an architecture uv publishes no build for, and asking
507
+ # for the wrong one produces an "Exec format error" that names no cause.
508
+ [ -n "$UV_TARGET" ] || return 1
509
+ command -v curl >/dev/null 2>&1 || return 1
510
+ local tmp
511
+ tmp="$(mktemp -d 2>/dev/null)" || return 1
512
+ if curl -fsSL "https://github.com/astral-sh/uv/releases/latest/download/uv-${UV_TARGET}.tar.gz" \
513
+ -o "$tmp/uv.tar.gz" 2>/dev/null; then
514
+ if tar -xzf "$tmp/uv.tar.gz" -C "$tmp" 2>/dev/null && [ -f "$tmp/uv-${UV_TARGET}/uv" ]; then
515
+ mkdir -p "$CONFIG_DIR/bin"
516
+ cp "$tmp/uv-${UV_TARGET}/uv" "$CONFIG_DIR/bin/uv" 2>/dev/null && chmod +x "$CONFIG_DIR/bin/uv"
517
+ fi
518
+ fi
519
+ rm -rf "$tmp"
520
+ if [ -x "$CONFIG_DIR/bin/uv" ]; then
521
+ UV_BIN="$CONFIG_DIR/bin/uv"
522
+ return 0
523
+ fi
524
+ return 1
525
+ }
526
+
527
+ uv_pip_install() {
528
+ [ -n "$UV_BIN" ] || return 1
529
+ "$UV_BIN" pip install --python "$1" websockets >/dev/null 2>&1
530
+ }
531
+
532
+ # The two packages that make a Termux node behave like a supervised Linux node
533
+ # rather than a bare process: termux-services (runit: Restart=always) and
534
+ # termux-api (every termux-* command the device branch uses; the Termux:API app
535
+ # must be installed as well for these to answer). Both are optional - the install
536
+ # degrades instead of failing - so this never reports an error.
537
+ ensure_termux_extras() {
538
+ [ "$IS_TERMUX" = true ] || return 0
539
+ command -v pkg >/dev/null 2>&1 || return 0
540
+ local missing=""
541
+ command -v sv >/dev/null 2>&1 || missing="termux-services"
542
+ # termux-wake-lock is NOT proof that termux-api is installed: on a real phone
543
+ # (Android 16, OPPO PHY110) that one script existed while termux-battery-status,
544
+ # termux-wifi-connectioninfo, termux-sensor and every other command were
545
+ # missing. Checking the wake lock therefore declared the package present and
546
+ # left the whole device branch dead with nothing to explain it. A command that
547
+ # only termux-api ships is the honest probe.
548
+ command -v termux-battery-status >/dev/null 2>&1 || missing="$missing termux-api"
549
+ # Termux ships pip separately from python, and its interpreter is built
550
+ # --without-ensurepip: without these two packages neither `pip install` nor the
551
+ # venv fallback below can work, and the failure would look like a broken node
552
+ # rather than a missing package.
553
+ if ! python_has_pip "$PYTHON_BIN"; then
554
+ missing="$missing python-pip python-ensurepip-wheels"
555
+ fi
556
+ [ -n "$missing" ] || return 0
557
+ # Unquoted on purpose: this is a package list, and pkg install takes them all.
558
+ # shellcheck disable=SC2086
559
+ pkg install -y $missing >/dev/null 2>&1 || true
560
+ return 0
561
+ }
562
+
563
+ # True when a usable Python 3 is on the machine. Installs one if it is not.
564
+ ensure_python() {
565
+ resolve_python && return 0
566
+ if [ "$LANG_CHOICE" = "ru" ]; then
567
+ echo -e "\033[1;33m[!] Python 3 не найден. Автоматическая установка системных пакетов...\033[0m"
568
+ else
569
+ echo -e "\033[1;33m[!] Python 3 not found. Installing system packages...\033[0m"
54
570
  fi
55
- [ "$LANG_CHOICE" = "ru" ] && echo -e "\033[1;33m[!] Python 3 не найден. Автоматическая установка системных пакетов...\033[0m" || echo -e "\033[1;33m[!] Python 3 not found. Installing system packages...\033[0m"
56
- if command -v apt-get >/dev/null 2>&1; then
57
- [ "$(id -u)" -eq 0 ] && apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv curl || sudo apt-get update -qq && sudo apt-get install -y -qq python3 python3-pip python3-venv curl
58
- elif command -v dnf >/dev/null 2>&1; then
59
- [ "$(id -u)" -eq 0 ] && dnf install -y -q python3 python3-pip curl || sudo dnf install -y -q python3 python3-pip curl
60
- elif command -v yum >/dev/null 2>&1; then
61
- [ "$(id -u)" -eq 0 ] && yum install -y -q python3 python3-pip curl || sudo yum install -y -q python3 python3-pip curl
62
- elif command -v pacman >/dev/null 2>&1; then
63
- [ "$(id -u)" -eq 0 ] && pacman -Sy --noconfirm python python-pip curl || sudo pacman -Sy --noconfirm python python-pip curl
64
- elif command -v apk >/dev/null 2>&1; then
65
- apk add --no-cache python3 py3-pip curl
66
- elif [ "$(uname -s)" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
67
- brew install python3
571
+ install_python_packages || true
572
+ resolve_python && return 0
573
+
574
+ # No usable package manager: uv can still bring an interpreter of its own.
575
+ if [ "$LANG_CHOICE" = "ru" ]; then
576
+ echo -e "\033[1;33m[!] Пакетный менеджер не дал Python. Пробую uv...\033[0m"
577
+ else
578
+ echo -e "\033[1;33m[!] The package manager did not provide Python. Trying uv...\033[0m"
579
+ fi
580
+ if ensure_uv && "$UV_BIN" python install 3.12 >/dev/null 2>&1; then
581
+ resolve_python && return 0
68
582
  fi
583
+ return 1
69
584
  }
70
585
 
71
586
  ensure_dependencies() {
72
- ensure_python
73
- PYTHON_BIN="$(command -v python3 || echo "/usr/bin/python3")"
74
- if ! "$PYTHON_BIN" -c "import websockets" 2>/dev/null; then
75
- [ "$LANG_CHOICE" = "ru" ] && echo "[1/4] Автономная установка зависимостей (websockets)..." || echo "[1/4] Installing dependencies (websockets)..."
76
- "$PYTHON_BIN" -m pip install websockets --break-system-packages 2>/dev/null || \
77
- "$PYTHON_BIN" -m pip install websockets 2>/dev/null || {
78
- mkdir -p "$CONFIG_DIR"
79
- local VENV_DIR="$CONFIG_DIR/venv"
80
- if ! "$PYTHON_BIN" -m venv "$VENV_DIR" 2>/dev/null; then
81
- if command -v apt-get >/dev/null 2>&1; then
82
- [ "$(id -u)" -eq 0 ] && apt-get install -y -qq python3-venv 2>/dev/null || sudo apt-get install -y -qq python3-venv 2>/dev/null || true
83
- "$PYTHON_BIN" -m venv "$VENV_DIR" 2>/dev/null || true
84
- fi
587
+ if ! ensure_python; then
588
+ # Fail closed. An autostart unit naming an interpreter that does not exist
589
+ # is a node that never connects, and nothing in the output would have said so.
590
+ if [ "$LANG_CHOICE" = "ru" ]; then
591
+ echo -e "\033[1;31m[!] Рабочий Python 3 получить не удалось.\033[0m" >&2
592
+ if [ "$IS_TERMUX" = true ]; then
593
+ echo -e " Проверено: ${CYAN}pkg${RESET} (пакетный менеджер Termux; uv не используется," >&2
594
+ echo -e " его сборки линкуются с glibc и на Android/bionic не запускаются)." >&2
595
+ echo -e " Установите Python 3 вручную и повторите:" >&2
596
+ echo -e " ${CYAN}pkg install python python-pip${RESET}" >&2
597
+ else
598
+ echo -e " Проверены: пакетный менеджер (apt/dnf/yum/zypper/pacman/apk/xbps/brew) и uv." >&2
599
+ echo -e " Установите Python 3 вручную и повторите:" >&2
600
+ echo -e " ${CYAN}sudo apt install python3 python3-pip python3-venv${RESET} # или ваш менеджер" >&2
85
601
  fi
86
- if [ -f "$VENV_DIR/bin/pip" ]; then
87
- "$VENV_DIR/bin/pip" install --quiet websockets
88
- PYTHON_BIN="$VENV_DIR/bin/python3"
602
+ echo -e " Остановка: без интерпретатора автозапуск узла работать не будет, ничего не создано." >&2
603
+ else
604
+ echo -e "\033[1;31m[!] Could not obtain a working Python 3.\033[0m" >&2
605
+ if [ "$IS_TERMUX" = true ]; then
606
+ echo -e " Tried: ${CYAN}pkg${RESET}, the Termux package manager (uv is skipped there: its" >&2
607
+ echo -e " builds link against glibc and cannot run on Android's bionic libc)." >&2
608
+ echo -e " Install Python 3 by hand and re-run:" >&2
609
+ echo -e " ${CYAN}pkg install python python-pip${RESET}" >&2
610
+ else
611
+ echo -e " Tried: the system package manager (apt/dnf/yum/zypper/pacman/apk/xbps/brew) and uv." >&2
612
+ echo -e " Install Python 3 by hand and re-run:" >&2
613
+ echo -e " ${CYAN}sudo apt install python3 python3-pip python3-venv${RESET} # or your package manager" >&2
89
614
  fi
90
- }
615
+ echo -e " Stopping: an autostart entry without an interpreter can never work, nothing was created." >&2
616
+ fi
617
+ exit 1
618
+ fi
619
+
620
+ ensure_termux_extras
621
+
622
+ if python_has_websockets "$PYTHON_BIN"; then
623
+ return 0
624
+ fi
625
+ if [ "$LANG_CHOICE" = "ru" ]; then
626
+ echo "[1/4] Автономная установка зависимостей (websockets)..."
627
+ else
628
+ echo "[1/4] Installing dependencies (websockets)..."
629
+ fi
630
+
631
+ # 1. pip, in both spellings. The --break-system-packages retry is the escape
632
+ # hatch a PEP 668 distribution documents; without it a Debian 12 or Fedora 38
633
+ # refuses the install and the message never mentions the flag.
634
+ "$PYTHON_BIN" -m pip install --quiet websockets 2>/dev/null || true
635
+ python_has_websockets "$PYTHON_BIN" && return 0
636
+ "$PYTHON_BIN" -m pip install --quiet --break-system-packages websockets 2>/dev/null || true
637
+ python_has_websockets "$PYTHON_BIN" && return 0
638
+
639
+ # 2. --user: site-packages is read-only, the home directory is not.
640
+ "$PYTHON_BIN" -m pip install --quiet --user websockets 2>/dev/null || true
641
+ python_has_websockets "$PYTHON_BIN" && return 0
642
+
643
+ # 3. ensurepip: a distribution can ship python3 without a pip at all.
644
+ "$PYTHON_BIN" -m ensurepip --default-pip >/dev/null 2>&1 || true
645
+ "$PYTHON_BIN" -m pip install --quiet websockets 2>/dev/null || true
646
+ python_has_websockets "$PYTHON_BIN" && return 0
647
+
648
+ # 4. uv: installs into a managed environment without the interpreter's own pip.
649
+ if ensure_uv; then
650
+ uv_pip_install "$PYTHON_BIN" || true
651
+ python_has_websockets "$PYTHON_BIN" && return 0
652
+ fi
653
+
654
+ # 5. venv: needs no write access to the system Python, only the interpreter.
655
+ local venv_dir="$CONFIG_DIR/venv"
656
+ if [ ! -x "$venv_dir/bin/python3" ]; then
657
+ "$PYTHON_BIN" -m venv "$venv_dir" >/dev/null 2>&1 || true
658
+ fi
659
+ if [ -x "$venv_dir/bin/python3" ]; then
660
+ "$venv_dir/bin/python3" -m pip install --quiet --upgrade pip >/dev/null 2>&1 || true
661
+ "$venv_dir/bin/python3" -m pip install --quiet websockets >/dev/null 2>&1 || true
662
+ if python_has_websockets "$venv_dir/bin/python3"; then
663
+ PYTHON_BIN="$venv_dir/bin/python3"
664
+ return 0
665
+ fi
666
+ if uv_pip_install "$venv_dir/bin/python3" && python_has_websockets "$venv_dir/bin/python3"; then
667
+ PYTHON_BIN="$venv_dir/bin/python3"
668
+ return 0
669
+ fi
91
670
  fi
92
- PYTHON_BIN="${PYTHON_BIN:-$(command -v python3 || echo "/usr/bin/python3")}"
671
+
672
+ if [ "$LANG_CHOICE" = "ru" ]; then
673
+ echo -e "\033[1;31m[!] Не удалось установить websockets автоматически.\033[0m" >&2
674
+ echo -e " Проверены: pip, pip --break-system-packages, pip --user, ensurepip, uv и venv." >&2
675
+ echo -e " Выполните вручную: ${CYAN}$PYTHON_BIN -m pip install websockets${RESET}" >&2
676
+ if [ "$IS_TERMUX" = true ]; then
677
+ echo -e " Termux: ${CYAN}pkg install python-pip python-ensurepip-wheels${RESET}, затем повторите." >&2
678
+ fi
679
+ echo -e " Остановка: узел без websockets не сможет подключиться к шлюзу." >&2
680
+ else
681
+ echo -e "\033[1;31m[!] Could not install websockets automatically.\033[0m" >&2
682
+ echo -e " Tried: pip, pip --break-system-packages, pip --user, ensurepip, uv and a venv." >&2
683
+ echo -e " Run manually: ${CYAN}$PYTHON_BIN -m pip install websockets${RESET}" >&2
684
+ if [ "$IS_TERMUX" = true ]; then
685
+ echo -e " Termux: ${CYAN}pkg install python-pip python-ensurepip-wheels${RESET}, then re-run." >&2
686
+ fi
687
+ echo -e " Stopping: a node without websockets can never reach the gateway." >&2
688
+ fi
689
+ exit 1
93
690
  }
94
691
 
95
692
 
@@ -118,7 +715,8 @@ while [[ $# -gt 0 ]]; do
118
715
  shift
119
716
  ;;
120
717
  --gateway=*)
121
- GATEWAY="${1#*=}"
718
+ # Historical alias of --domain=: both are the explicit override.
719
+ DOMAIN="${1#*=}"
122
720
  shift
123
721
  ;;
124
722
  --port=*)
@@ -155,6 +753,15 @@ while [[ $# -gt 0 ]]; do
155
753
  echo ""
156
754
  echo "All nodes use ONE shared domain; the node is selected by ?user=<node-name>:"
157
755
  echo " MCP URL: https://<shared-domain>/sse?user=<node-name>&token=<token>"
756
+ echo ""
757
+ echo "The shared domain is resolved from the one place that declares it, in this order:"
758
+ echo " --domain= / --gateway=, then MESH_PUBLIC_URL, then AGY_PUBLIC_BASE_URL,"
759
+ echo " then ${DEFAULT_DOMAIN_FILE}, then the value this copy was published with,"
760
+ echo " then core/domain.py's default when the repository is present."
761
+ echo ""
762
+ echo "Android/Termux is detected automatically: packages come from pkg, the node is"
763
+ echo "named after the device model, and autostart is a runit service plus the"
764
+ echo "Termux:Boot app instead of systemd (see docs/TERMUX.md)."
158
765
  exit 0
159
766
  ;;
160
767
  *)
@@ -164,6 +771,145 @@ while [[ $# -gt 0 ]]; do
164
771
  esac
165
772
  done
166
773
 
774
+ # ------------------------------------------------------------------------------
775
+ # Resolve the shared domain, then fetch the node code from it.
776
+ #
777
+ # Order matters: the CLI arguments are parsed first (so --domain= counts), the
778
+ # domain is resolved second, and only then is the bootstrap downloaded - from
779
+ # the resolved domain, never from a name baked into this file.
780
+ # ------------------------------------------------------------------------------
781
+ GATEWAY="$(resolve_domain "$DOMAIN")"
782
+
783
+ if [ "$GATEWAY" = "$DOMAIN_PLACEHOLDER" ]; then
784
+ if domain_required; then
785
+ echo ""
786
+ if [ "$LANG_CHOICE" = "ru" ]; then
787
+ echo -e "${RED}${BOLD}[ОШИБКА] Домен шлюза не задан.${RESET}"
788
+ echo -e " В этой копии установщика остался плейсхолдер ${BOLD}${DOMAIN_PLACEHOLDER}${RESET}: она не была опубликована"
789
+ echo -e " скриптом deploy_gateway.sh, а домена нет ни в окружении, ни в файле ${BOLD}${DEFAULT_DOMAIN_FILE}${RESET}."
790
+ echo -e " Укажите домен одним из способов:"
791
+ echo -e " ${CYAN}./install.sh --domain=<общий-домен>${RESET}"
792
+ echo -e " ${CYAN}MESH_PUBLIC_URL=https://<общий-домен> ./install.sh${RESET}"
793
+ echo -e " ${CYAN}$(domain_file_write_cmd 'https://<общий-домен>')${RESET}"
794
+ echo -e " ${YELLOW}Установка остановлена: регистрация на несуществующем домене не выполняется, ничего не создано.${RESET}"
795
+ else
796
+ echo -e "${RED}${BOLD}[ERROR] No gateway domain configured.${RESET}"
797
+ echo -e " This copy of the installer still carries the ${BOLD}${DOMAIN_PLACEHOLDER}${RESET} placeholder: it was not"
798
+ echo -e " published by deploy_gateway.sh, and no domain was found in the environment or in ${BOLD}${DEFAULT_DOMAIN_FILE}${RESET}."
799
+ echo -e " Pass the domain in one of these ways:"
800
+ echo -e " ${CYAN}./install.sh --domain=<shared-domain>${RESET}"
801
+ echo -e " ${CYAN}MESH_PUBLIC_URL=https://<shared-domain> ./install.sh${RESET}"
802
+ echo -e " ${CYAN}$(domain_file_write_cmd 'https://<shared-domain>')${RESET}"
803
+ echo -e " ${YELLOW}Stopping: a node is never registered against a domain that does not exist, nothing was created.${RESET}"
804
+ fi
805
+ echo ""
806
+ exit 1
807
+ fi
808
+ # Standalone install with the node code already next to this script: no domain
809
+ # is needed, so this is a warning rather than a stop.
810
+ GATEWAY=""
811
+ if [ "$LANG_CHOICE" = "ru" ]; then
812
+ echo -e "${YELLOW}[!] Домен не задан - автономному режиму он не нужен, продолжаю.${RESET}"
813
+ else
814
+ echo -e "${YELLOW}[!] No domain configured - not needed for standalone mode, continuing.${RESET}"
815
+ fi
816
+ fi
817
+
818
+ # ------------------------------------------------------------------------------
819
+ # GATEWAY REACHABILITY (the one preflight that cannot be deferred)
820
+ #
821
+ # A node downloads its code from the gateway, registers there, and then dials the
822
+ # same name for its tunnel. A name this device cannot resolve therefore dooms
823
+ # every later step - and the failure used to surface much later, as "Failed to
824
+ # obtain authentication token", which blames the gateway's answer instead of the
825
+ # missing DNS record. Phones are where this bites: a gateway on a private network
826
+ # (Tailscale, a VPN, a DNS override on the operator's laptop) resolves on that
827
+ # laptop and nowhere else, and mobile data resolves nothing private.
828
+ #
829
+ # Only curl's exit code 6 ("could not resolve host") stops the install: it is the
830
+ # one answer that cannot become true later in this run. A timeout, a 404 or a 5xx
831
+ # is not fatal - a gateway without /health still serves the tunnel, and refusing
832
+ # to install for that would be wrong.
833
+ # ------------------------------------------------------------------------------
834
+ require_gateway_dns() {
835
+ [ -n "$GATEWAY" ] || return 0
836
+ command -v curl >/dev/null 2>&1 || return 0
837
+ local rc=0
838
+ curl -fsS -m 8 -o /dev/null "https://${GATEWAY}/health" >/dev/null 2>&1 || rc=$?
839
+ [ "$rc" -eq 6 ] || return 0
840
+
841
+ if [ "$LANG_CHOICE" = "ru" ]; then
842
+ echo ""
843
+ echo -e "${RED}${BOLD}[ОШИБКА] Имя шлюза не разрешается на этом устройстве: ${GATEWAY}${RESET}"
844
+ echo -e " DNS не знает такого имени, поэтому ни скачать код узла, ни зарегистрировать его не выйдет."
845
+ echo -e " Обычно так бывает, когда шлюз живёт в приватной сети (Tailscale, VPN, локальный DNS):"
846
+ echo -e " на рабочем ноутбуке имя резолвится, а на телефоне в мобильной сети - нет."
847
+ echo -e " Что делать:"
848
+ echo -e " • подключить это устройство к той же сети (на Android - приложение Tailscale);"
849
+ echo -e " • или указать домен, который резолвится публично: ${CYAN}--domain=<домен>${RESET};"
850
+ echo -e " • проверить вручную: ${CYAN}curl -v https://${GATEWAY}/health${RESET}"
851
+ echo -e " ${YELLOW}Остановка: без DNS узел не подключится к шлюзу, ничего не создано.${RESET}"
852
+ echo ""
853
+ else
854
+ echo ""
855
+ echo -e "${RED}${BOLD}[ERROR] This device cannot resolve the gateway name: ${GATEWAY}${RESET}"
856
+ echo -e " DNS does not know the name, so neither the node code nor a registration can arrive."
857
+ echo -e " This is the usual state of a gateway on a private network (Tailscale, a VPN, a local"
858
+ echo -e " DNS override): it resolves on the operator's laptop and not on a phone on mobile data."
859
+ echo -e " What to do:"
860
+ echo -e " • put this device on the same network (on Android: the Tailscale app);"
861
+ echo -e " • or name a domain that resolves publicly: ${CYAN}--domain=<domain>${RESET};"
862
+ echo -e " • check by hand: ${CYAN}curl -v https://${GATEWAY}/health${RESET}"
863
+ echo -e " ${YELLOW}Stopping: without DNS the node can never reach the gateway, nothing was created.${RESET}"
864
+ echo ""
865
+ fi
866
+ return 1
867
+ }
868
+
869
+ # Bootstrap when piped from curl or run outside the project: the node code is
870
+ # fetched from the SAME domain that was just resolved. core/domain.py travels with
871
+ # it, because the node asks that module for the domain as well.
872
+ if [ ! -f "$SCRIPT_DIR/core/agent.py" ]; then
873
+ # The node code is fetched with curl. Piping this script through curl means curl
874
+ # exists; arriving through wget, a copy-paste or an embedded runner does not.
875
+ ensure_curl || exit 1
876
+ require_gateway_dns || exit 1
877
+ BOOTSTRAP_DIR="$HOME/.gemini-computer-use"
878
+ mkdir -p "$BOOTSTRAP_DIR/core" "$BOOTSTRAP_DIR/skills"
879
+ for f in core/agent.py core/server.py core/mcp_tools.py core/web_share.py core/domain.py core/vitals.py core/updater.py core/version.py core/__init__.py skills/orchestrator.md; do
880
+ bootstrap_tmp="$BOOTSTRAP_DIR/${f}.part"
881
+ if curl -fsSL "https://${GATEWAY}/${f}" -o "$bootstrap_tmp" 2>/dev/null \
882
+ || curl -fsSL "https://raw.githubusercontent.com/LevRa7/Computer-use-for-Gemini-App-Web/main/${f}" -o "$bootstrap_tmp" 2>/dev/null; then
883
+ mv "$bootstrap_tmp" "$BOOTSTRAP_DIR/${f}"
884
+ else
885
+ rm -f "$bootstrap_tmp"
886
+ fi
887
+ done
888
+ unset bootstrap_tmp
889
+ # A bootstrap that downloaded nothing (an unreachable gateway and no GitHub)
890
+ # leaves a directory that names modules which do not exist: every step after it
891
+ # would write a launcher for code that is not there, and the node would fail
892
+ # with a Python import error at boot.
893
+ if [ ! -f "$BOOTSTRAP_DIR/core/agent.py" ]; then
894
+ if [ "$LANG_CHOICE" = "ru" ]; then
895
+ echo -e "${RED}${BOLD}[ОШИБКА] Не удалось скачать код узла.${RESET}" >&2
896
+ echo -e " Пробовали: ${CYAN}https://${GATEWAY}/core/agent.py${RESET} и ${CYAN}github.com/LevRa7/Computer-use-for-Gemini-App-Web${RESET}." >&2
897
+ echo -e " Проверьте сеть и DNS либо скопируйте репозиторий на устройство и запустите из него:" >&2
898
+ echo -e " ${CYAN}git clone https://github.com/LevRa7/Computer-use-for-Gemini-App-Web.git && cd Computer-use-for-Gemini-App-Web && ./install.sh --quick${RESET}" >&2
899
+ echo -e " ${YELLOW}Остановка: без кода узла запускать нечего, ничего не создано.${RESET}" >&2
900
+ else
901
+ echo -e "${RED}${BOLD}[ERROR] Could not download the node code.${RESET}" >&2
902
+ echo -e " Tried: ${CYAN}https://${GATEWAY}/core/agent.py${RESET} and ${CYAN}github.com/LevRa7/Computer-use-for-Gemini-App-Web${RESET}." >&2
903
+ echo -e " Check the network and DNS, or put the repository on this device and install from it:" >&2
904
+ echo -e " ${CYAN}git clone https://github.com/LevRa7/Computer-use-for-Gemini-App-Web.git && cd Computer-use-for-Gemini-App-Web && ./install.sh --quick${RESET}" >&2
905
+ echo -e " ${YELLOW}Stopping: there is no node code to run, nothing was created.${RESET}" >&2
906
+ fi
907
+ echo ""
908
+ exit 1
909
+ fi
910
+ SCRIPT_DIR="$BOOTSTRAP_DIR"
911
+ fi
912
+
167
913
  # ------------------------------------------------------------------------------
168
914
  # SHARED-DOMAIN CONTRACT (canonical step)
169
915
  # Every node is reached through ONE public domain ($GATEWAY); the node is
@@ -198,11 +944,71 @@ if [ -z "$LANG_CHOICE" ]; then
198
944
  fi
199
945
 
200
946
  # Device detection
201
- detect_device() {
202
- DETECTED_HOSTNAME=$(hostname -s 2>/dev/null || hostname)
203
- DETECTED_ARCH=$(uname -m)
947
+ # ------------------------------------------------------------------------------
948
+ # ARCHITECTURE
949
+ #
950
+ # The system package managers resolve their own architecture, so on Linux this
951
+ # matters for exactly two things: the uv archive, which is published per CPU and
952
+ # answers the wrong one with an unhelpful "Exec format error", and the
953
+ # registration payload, so a gateway operator can see which build a node runs.
954
+ # macOS reports the same machine names as Linux but needs the -apple-darwin assets.
955
+ # ------------------------------------------------------------------------------
956
+ detect_arch() {
957
+ DETECTED_ARCH="$(uname -m 2>/dev/null || echo unknown)"
958
+ case "$DETECTED_ARCH" in
959
+ x86_64|amd64) ARCH_LABEL="x86_64 (64-bit)"; UV_TARGET="x86_64-unknown-linux-gnu" ;;
960
+ aarch64|arm64) ARCH_LABEL="aarch64 (64-bit ARM)"; UV_TARGET="aarch64-unknown-linux-gnu" ;;
961
+ armv7l|armv7|armv6l) ARCH_LABEL="armv7 (32-bit ARM)"; UV_TARGET="armv7-unknown-linux-gnueabihf" ;;
962
+ i386|i486|i586|i686) ARCH_LABEL="x86 (32-bit)"; UV_TARGET="i686-unknown-linux-gnu" ;;
963
+ *) ARCH_LABEL="$DETECTED_ARCH"; UV_TARGET="" ;;
964
+ esac
965
+ if [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then
966
+ case "$DETECTED_ARCH" in
967
+ x86_64) UV_TARGET="x86_64-apple-darwin" ;;
968
+ arm64) UV_TARGET="aarch64-apple-darwin" ;;
969
+ esac
970
+ fi
971
+ if [ "$IS_TERMUX" = true ]; then
972
+ # uv's archives are glibc/musl builds; neither runs against Android's
973
+ # bionic libc, and asking for one yields an "Exec format error" that names
974
+ # no cause. On a phone the package manager is the only source of Python.
975
+ UV_TARGET=""
976
+ fi
977
+ }
204
978
 
205
- if [ "$(uname -s)" = "Darwin" ]; then
979
+ # Android system properties. getprop lives in /system/bin on every Android build
980
+ # and is readable without root; a missing getprop (or an empty property) simply
981
+ # yields an empty value, never a failed install.
982
+ android_prop() {
983
+ command -v getprop >/dev/null 2>&1 || return 0
984
+ getprop "$1" 2>/dev/null | tr -d '\r' | head -n 1
985
+ }
986
+
987
+ detect_device() {
988
+ # `|| true` on purpose: a minimal container (and Termux, which has no
989
+ # hostname(1) of its own) answers neither spelling, and an assignment whose
990
+ # substitution fails would abort the whole installer under `set -e`.
991
+ DETECTED_HOSTNAME=$(hostname -s 2>/dev/null || hostname 2>/dev/null || true)
992
+ detect_arch
993
+
994
+ if [ "$IS_TERMUX" = true ]; then
995
+ ANDROID_MODEL="$(android_prop ro.product.model)"
996
+ [ -n "$ANDROID_MODEL" ] || ANDROID_MODEL="$(android_prop ro.product.device)"
997
+ ANDROID_RELEASE="$(android_prop ro.build.version.release)"
998
+ [ -n "$ANDROID_RELEASE" ] || ANDROID_RELEASE="$(android_prop ro.build.version.sdk)"
999
+ DETECTED_OS="Android ${ANDROID_RELEASE:-unknown} (Termux)"
1000
+ # The phone itself names the node. Termux has no hostname binary, and
1001
+ # Android's gethostname(2) answers "localhost" - a name every phone would
1002
+ # claim, and the gateway keeps exactly one tunnel per name, so they would
1003
+ # knock each other offline in a loop. The device model is stable, and it is
1004
+ # sanitised below exactly like a hostname.
1005
+ DETECTED_HOSTNAME="${ANDROID_MODEL:-android-node}"
1006
+ if [ "$LANG_CHOICE" = "ru" ]; then
1007
+ DETECTED_TYPE="Смартфон/планшет Android (Termux)"
1008
+ else
1009
+ DETECTED_TYPE="Android phone/tablet (Termux)"
1010
+ fi
1011
+ elif [ "$(uname -s)" = "Darwin" ]; then
206
1012
  DETECTED_OS="macOS $(sw_vers -productVersion 2>/dev/null || '')"
207
1013
  if [ "$LANG_CHOICE" = "ru" ]; then
208
1014
  DETECTED_TYPE="Apple Mac"
@@ -219,26 +1025,31 @@ detect_device() {
219
1025
  DETECTED_OS=$(uname -s)
220
1026
  fi
221
1027
 
222
- if [ "$(uname -s)" != "Darwin" ]; then
1028
+ if [ "$(uname -s)" != "Darwin" ] && [ "$IS_TERMUX" != true ]; then
1029
+ # The virtualised environments are asked FIRST, before the battery. WSL2
1030
+ # forwards the host's battery into /sys/class/power_supply, so the battery
1031
+ # test matched inside WSL and reported the user's laptop instead of the
1032
+ # subsystem they were actually installing into - the node registered as
1033
+ # "Laptop" while running under Windows.
223
1034
  if [ "$LANG_CHOICE" = "ru" ]; then
224
1035
  DETECTED_TYPE="Десктоп / Сервер"
225
- if [ -d /sys/class/power_supply ] && ls /sys/class/power_supply/BAT* 1>/dev/null 2>&1; then
226
- DETECTED_TYPE="Ноутбук (Laptop)"
227
- elif grep -q -i "microsoft" /proc/version 2>/dev/null; then
1036
+ if grep -q -i "microsoft" /proc/version 2>/dev/null; then
228
1037
  DETECTED_TYPE="WSL (Windows Subsystem for Linux)"
229
1038
  elif [ -f /.dockerenv ] || grep -q "docker\|containerd" /proc/1/cgroup 2>/dev/null; then
230
1039
  DETECTED_TYPE="Контейнер (Docker/LXC)"
1040
+ elif [ -d /sys/class/power_supply ] && ls /sys/class/power_supply/BAT* 1>/dev/null 2>&1; then
1041
+ DETECTED_TYPE="Ноутбук (Laptop)"
231
1042
  elif command -v systemd-detect-virt >/dev/null 2>&1 && systemd-detect-virt -q; then
232
1043
  DETECTED_TYPE="Облачный сервер / VPS ($(systemd-detect-virt))"
233
1044
  fi
234
1045
  else
235
1046
  DETECTED_TYPE="Desktop / Server"
236
- if [ -d /sys/class/power_supply ] && ls /sys/class/power_supply/BAT* 1>/dev/null 2>&1; then
237
- DETECTED_TYPE="Laptop"
238
- elif grep -q -i "microsoft" /proc/version 2>/dev/null; then
1047
+ if grep -q -i "microsoft" /proc/version 2>/dev/null; then
239
1048
  DETECTED_TYPE="WSL (Windows Subsystem for Linux)"
240
1049
  elif [ -f /.dockerenv ] || grep -q "docker\|containerd" /proc/1/cgroup 2>/dev/null; then
241
1050
  DETECTED_TYPE="Container (Docker/LXC)"
1051
+ elif [ -d /sys/class/power_supply ] && ls /sys/class/power_supply/BAT* 1>/dev/null 2>&1; then
1052
+ DETECTED_TYPE="Laptop"
242
1053
  elif command -v systemd-detect-virt >/dev/null 2>&1 && systemd-detect-virt -q; then
243
1054
  DETECTED_TYPE="Cloud VPS ($(systemd-detect-virt))"
244
1055
  fi
@@ -255,7 +1066,7 @@ print_device_info() {
255
1066
  echo -e "${CYAN}║${RESET} • Имя хоста : ${GREEN}${DETECTED_HOSTNAME}${RESET}"
256
1067
  echo -e "${CYAN}║${RESET} • Тип : ${YELLOW}${DETECTED_TYPE}${RESET}"
257
1068
  echo -e "${CYAN}║${RESET} • ОС : ${DETECTED_OS}"
258
- echo -e "${CYAN}║${RESET} • Архитектура : ${DETECTED_ARCH}"
1069
+ echo -e "${CYAN}║${RESET} • Архитектура : ${ARCH_LABEL}"
259
1070
  echo -e "${CYAN}╚════════════════════════════════════════════════════════════════════════╝${RESET}"
260
1071
  else
261
1072
  echo -e "${CYAN}╔════════════════════════════════════════════════════════════════════════╗${RESET}"
@@ -263,7 +1074,7 @@ print_device_info() {
263
1074
  echo -e "${CYAN}║${RESET} • Hostname : ${GREEN}${DETECTED_HOSTNAME}${RESET}"
264
1075
  echo -e "${CYAN}║${RESET} • Type : ${YELLOW}${DETECTED_TYPE}${RESET}"
265
1076
  echo -e "${CYAN}║${RESET} • OS : ${DETECTED_OS}"
266
- echo -e "${CYAN}║${RESET} • Architecture: ${DETECTED_ARCH}"
1077
+ echo -e "${CYAN}║${RESET} • Architecture: ${ARCH_LABEL}"
267
1078
  echo -e "${CYAN}╚════════════════════════════════════════════════════════════════════════╝${RESET}"
268
1079
  fi
269
1080
  }
@@ -307,21 +1118,253 @@ print_mcp_box() {
307
1118
  echo -e "${GREEN}${border}${RESET}"
308
1119
  }
309
1120
 
1121
+ # ------------------------------------------------------------------------------
1122
+ # TERMUX (ANDROID) AUTOSTART
1123
+ #
1124
+ # An unprivileged Android app gets no service manager, so there is no systemd
1125
+ # unit, no launchd agent and no init script to install. Two Termux mechanisms
1126
+ # replace all three, and configure_termux_node sets up both:
1127
+ #
1128
+ # 1. runit, from the termux-services package: $PREFIX/var/service/<name>/run is
1129
+ # supervised inside the running Termux session, so a node that dies is
1130
+ # restarted - the systemd Restart=always equivalent;
1131
+ # 2. ~/.termux/boot/<name>.sh, executed by the Termux:Boot app (F-Droid) after
1132
+ # the device boots: it takes the wake lock and starts the supervisor.
1133
+ #
1134
+ # Without termux-services the node still runs and still autostarts at boot, but
1135
+ # nothing restarts it when Android kills the process. The installer says that
1136
+ # plainly instead of implying supervision that is not there.
1137
+ #
1138
+ # configure_termux_node <service name> <python module> <extra args> <env file>
1139
+ # ------------------------------------------------------------------------------
1140
+ configure_termux_node() {
1141
+ local name="$1" module="$2" extra="$3" env_file="$4"
1142
+ local run_script="$CONFIG_DIR/${name}.sh"
1143
+ local boot_script="$HOME/.termux/boot/${name}.sh"
1144
+ local svdir="$TERMUX_PREFIX/var/service"
1145
+ local svlogdir="$TERMUX_PREFIX/var/log"
1146
+ local log_file="$CONFIG_DIR/${name}.log"
1147
+ local env_block=""
1148
+ local module_args="$module"
1149
+ local alive=false
1150
+ local waited=0
1151
+
1152
+ [ -n "$extra" ] && module_args="$module $extra"
1153
+
1154
+ TERMUX_SUPERVISED=false
1155
+ mkdir -p "$CONFIG_DIR" "$HOME/.termux/boot"
1156
+
1157
+ if [ -n "$env_file" ]; then
1158
+ env_block="if [ -f \"$env_file\" ]; then
1159
+ set -a
1160
+ . \"$env_file\"
1161
+ set +a
1162
+ fi"
1163
+ fi
1164
+
1165
+ # The launcher is the one thing every mechanism below starts. Absolute paths
1166
+ # only: runit and the boot script run it with a minimal environment in which
1167
+ # $PATH may not even contain $PREFIX/bin.
1168
+ cat << RUNNER_EOF > "$run_script"
1169
+ #!$TERMUX_PREFIX/bin/sh
1170
+ # Antigravity Mesh - $name launcher (written by install.sh; do not edit).
1171
+ # Run this file to start the node by hand; docs/TERMUX.md documents the automatic
1172
+ # paths (runit service, Termux:Boot) and where the logs are.
1173
+ export PATH="$TERMUX_PREFIX/bin:/system/bin:\$PATH"
1174
+ $env_block
1175
+ cd "$SCRIPT_DIR" || exit 1
1176
+ exec "$PYTHON_BIN" -m $module_args
1177
+ RUNNER_EOF
1178
+ chmod 700 "$run_script"
1179
+
1180
+ # 1. runit supervision, when termux-services is installed. install.sh asks the
1181
+ # package manager for it first; a phone that refused (no network, an old
1182
+ # mirror) simply keeps the unattended launcher below.
1183
+ if command -v sv >/dev/null 2>&1 && command -v runsvdir >/dev/null 2>&1; then
1184
+ mkdir -p "$svdir/$name/log"
1185
+ cat << SV_EOF > "$svdir/$name/run"
1186
+ #!$TERMUX_PREFIX/bin/sh
1187
+ exec "$run_script"
1188
+ SV_EOF
1189
+ chmod 755 "$svdir/$name/run"
1190
+ # runit refuses to supervise without log/run, and svlogger is a script, not
1191
+ # a program: the documented setup is a symlink to it. A $PREFIX that refuses
1192
+ # symlinks (a FUSE-backed home, an unusual mount) gets an equivalent script
1193
+ # instead, so the node is never left unsupervised for want of a link.
1194
+ if ! ln -sf "$TERMUX_PREFIX/share/termux-services/svlogger" "$svdir/$name/log/run" 2>/dev/null; then
1195
+ printf '#!%s/bin/sh\nexec "%s/share/termux-services/svlogger"\n' \
1196
+ "$TERMUX_PREFIX" "$TERMUX_PREFIX" > "$svdir/$name/log/run"
1197
+ chmod 755 "$svdir/$name/log/run"
1198
+ fi
1199
+ export SVDIR="$svdir"
1200
+ export LOGDIR="$svlogdir"
1201
+ # The supervisor is normally started by the login shell (profile.d), which
1202
+ # has not necessarily run when this installer is piped straight into bash.
1203
+ # Starting it here is what makes the node run *now*; the pgrep guard keeps a
1204
+ # second runsvdir off an SVDIR that already has one.
1205
+ if ! pgrep -f "runsvdir" >/dev/null 2>&1; then
1206
+ command -v service-daemon >/dev/null 2>&1 && service-daemon start >/dev/null 2>&1 || true
1207
+ fi
1208
+ sv up "$name" >/dev/null 2>&1 || true
1209
+ sv-enable "$name" >/dev/null 2>&1 || true
1210
+ TERMUX_SUPERVISED=true
1211
+ fi
1212
+
1213
+ # 2. The boot entry, written even when runit supervises: after a reboot the
1214
+ # supervisor itself has to be started by something, and on Android that
1215
+ # something is the Termux:Boot app.
1216
+ cat << BOOT_EOF > "$boot_script"
1217
+ #!$TERMUX_PREFIX/bin/sh
1218
+ # Antigravity Mesh - starts the $name node after the device boots (Termux:Boot).
1219
+ #
1220
+ # Requirements, both of them outside this file (docs/TERMUX.md):
1221
+ # * the Termux:Boot app (F-Droid), opened once so Android lets it run;
1222
+ # * battery optimisation OFF for Termux and for Termux:Boot.
1223
+ export PATH="$TERMUX_PREFIX/bin:/system/bin:\$PATH"
1224
+ export HOME="$HOME"
1225
+ export SVDIR="$svdir"
1226
+ export LOGDIR="$svlogdir"
1227
+
1228
+ # A partial wake lock keeps Android from freezing the node while the screen is
1229
+ # off. It needs the termux-api package plus the Termux:API app; without either,
1230
+ # this line is a no-op rather than a failure.
1231
+ command -v termux-wake-lock >/dev/null 2>&1 && termux-wake-lock >/dev/null 2>&1
1232
+
1233
+ if command -v sv >/dev/null 2>&1 && [ -d "$svdir/$name" ]; then
1234
+ if ! pgrep -f "runsvdir" >/dev/null 2>&1; then
1235
+ command -v service-daemon >/dev/null 2>&1 && service-daemon start >/dev/null 2>&1
1236
+ fi
1237
+ sv up "$name" >/dev/null 2>&1
1238
+ else
1239
+ # No supervisor on this phone: start the launcher directly and keep its output.
1240
+ "$run_script" >> "$CONFIG_DIR/$name.boot.log" 2>&1 &
1241
+ fi
1242
+ BOOT_EOF
1243
+ chmod 700 "$boot_script"
1244
+
1245
+ if [ "$TERMUX_SUPERVISED" != true ]; then
1246
+ # The systemd branch's fallback, spelled for a phone: nothing supervises
1247
+ # the process, so it is started detached and its log is the only evidence.
1248
+ pkill -f "$module" 2>/dev/null || true
1249
+ nohup "$run_script" >> "$log_file" 2>&1 &
1250
+ fi
1251
+
1252
+ # Termux has no `systemctl status` to consult, so the installer waits for the
1253
+ # process it just started (runit may take a moment) and reports what it saw.
1254
+ while [ "$waited" -lt 10 ]; do
1255
+ if pgrep -f "$module" >/dev/null 2>&1; then
1256
+ alive=true
1257
+ break
1258
+ fi
1259
+ sleep 1
1260
+ waited=$((waited + 1))
1261
+ done
1262
+
1263
+ if [ "$LANG_CHOICE" = "ru" ]; then
1264
+ if [ "$TERMUX_SUPERVISED" = true ]; then
1265
+ echo -e "${GREEN}[OK] runit (termux-services) следит за узлом и перезапустит его сам.${RESET}"
1266
+ echo -e " Статус: ${CYAN}sv status $name${RESET} Логи: ${CYAN}$svlogdir/sv/$name/current${RESET}"
1267
+ else
1268
+ echo -e "${YELLOW}[!] Узел запущен без супервизора: termux-services не установлен.${RESET}"
1269
+ echo -e " Автоперезапуск после сбоя: ${CYAN}pkg install termux-services${RESET}, затем повторите установку."
1270
+ echo -e " Логи: ${CYAN}$log_file${RESET}"
1271
+ fi
1272
+ echo -e " Автозапуск после перезагрузки: ${CYAN}$boot_script${RESET} (нужно приложение Termux:Boot)."
1273
+ if [ "$alive" != true ]; then
1274
+ echo -e "${YELLOW}[!] Процесс узла пока не виден — смотрите лог: ${CYAN}$log_file${RESET}"
1275
+ fi
1276
+ else
1277
+ if [ "$TERMUX_SUPERVISED" = true ]; then
1278
+ echo -e "${GREEN}[OK] runit (termux-services) supervises the node and restarts it.${RESET}"
1279
+ echo -e " Status: ${CYAN}sv status $name${RESET} Logs: ${CYAN}$svlogdir/sv/$name/current${RESET}"
1280
+ else
1281
+ echo -e "${YELLOW}[!] The node runs unsupervised: termux-services is not installed.${RESET}"
1282
+ echo -e " Restart after a crash: ${CYAN}pkg install termux-services${RESET}, then re-run the installer."
1283
+ echo -e " Logs: ${CYAN}$log_file${RESET}"
1284
+ fi
1285
+ echo -e " Autostart after a reboot: ${CYAN}$boot_script${RESET} (the Termux:Boot app is required)."
1286
+ if [ "$alive" != true ]; then
1287
+ echo -e "${YELLOW}[!] The node process is not visible yet - check: ${CYAN}$log_file${RESET}"
1288
+ fi
1289
+ fi
1290
+ }
1291
+
1292
+ # The Android-side switches no installer can flip for the user: whether the node
1293
+ # comes back after a reboot and whether Android keeps it alive with the screen
1294
+ # off. Printed after the MCP URL, in the language of the run.
1295
+ print_termux_hints() {
1296
+ local name="$1"
1297
+ if [ "$LANG_CHOICE" = "ru" ]; then
1298
+ echo -e " ${BOLD}📱 Termux (Android): что проверить на телефоне${RESET} (служба: ${CYAN}$name${RESET})"
1299
+ echo -e " 1. Автозапуск после перезагрузки делает приложение ${CYAN}Termux:Boot${RESET} (F-Droid)."
1300
+ echo -e " Установите и ${BOLD}запустите его один раз${RESET}, иначе скрипт в ~/.termux/boot не выполнится."
1301
+ echo -e " 2. Отключите оптимизацию батареи для ${CYAN}Termux${RESET} и Termux:Boot: иначе Android"
1302
+ echo -e " выгружает фоновый процесс вместе с туннелем."
1303
+ echo -e " 3. Скопировать ссылку выше: долгое нажатие в терминале, либо приложение Termux:API и"
1304
+ echo -e " ${CYAN}termux-clipboard-set '<ссылка>'${RESET}."
1305
+ echo -e " 4. Выдайте приложению ${CYAN}Termux:API${RESET} разрешения для инструментов устройства"
1306
+ echo -e " (Настройки Android → Приложения → Termux:API → Разрешения): камера, микрофон,"
1307
+ echo -e " местоположение, SMS, журнал вызовов, контакты. Без них ${CYAN}device_capture${RESET} и"
1308
+ echo -e " ${CYAN}device_messages${RESET} ответят, какого именно разрешения не хватает."
1309
+ echo -e " 5. Ветка устройства включена по умолчанию (${CYAN}MESH_DEVICE=auto${RESET}). SMS, контакты и"
1310
+ echo -e " журнал вызовов открывает ${CYAN}MESH_DEVICE_PIM=1${RESET}; ${CYAN}MESH_DEVICE_QUICK=1${RESET} оставляет"
1311
+ echo -e " system_info мгновенным (без сети, камер, микрофонов и датчиков)."
1312
+ echo -e " 6. Полная инструкция: ${CYAN}docs/TERMUX.md${RESET} (установка, автозапуск, логи, удаление)."
1313
+ echo ""
1314
+ else
1315
+ echo -e " ${BOLD}📱 Termux (Android): what to check on the phone${RESET} (service: ${CYAN}$name${RESET})"
1316
+ echo -e " 1. Autostart after a reboot is done by the ${CYAN}Termux:Boot${RESET} app (F-Droid)."
1317
+ echo -e " Install it and ${BOLD}open it once${RESET}, or the script in ~/.termux/boot never runs."
1318
+ echo -e " 2. Turn battery optimisation OFF for ${CYAN}Termux${RESET} and Termux:Boot: otherwise Android"
1319
+ echo -e " unloads the background process together with the tunnel."
1320
+ echo -e " 3. Copy the URL above: long-press the terminal, or install the Termux:API app and use"
1321
+ echo -e " ${CYAN}termux-clipboard-set '<url>'${RESET}."
1322
+ echo -e " 4. Grant the ${CYAN}Termux:API${RESET} app the permissions the device tools need"
1323
+ echo -e " (Android Settings → Apps → Termux:API → Permissions): camera, microphone, location,"
1324
+ echo -e " SMS, call log, contacts. Without them ${CYAN}device_capture${RESET} / ${CYAN}device_messages${RESET}"
1325
+ echo -e " answer with the exact permission that is missing."
1326
+ echo -e " 5. The device branch is on by default (${CYAN}MESH_DEVICE=auto${RESET}). ${CYAN}MESH_DEVICE_PIM=1${RESET}"
1327
+ echo -e " enables SMS, contacts and the call log; ${CYAN}MESH_DEVICE_QUICK=1${RESET} keeps system_info"
1328
+ echo -e " instant (no network, cameras, microphones or sensors)."
1329
+ echo -e " 6. Full guide: ${CYAN}docs/TERMUX.md${RESET} (install, autostart, logs, uninstall)."
1330
+ echo ""
1331
+ fi
1332
+ }
1333
+
310
1334
  # Dry-run
311
1335
  if [ "$DRY_RUN" = true ]; then
312
1336
  echo "[DRY-RUN] Simulating Antigravity Mesh installation..."
313
1337
  echo "[DRY-RUN] Language: $LANG_CHOICE"
314
1338
  echo "[DRY-RUN] Detected Host: $DETECTED_HOSTNAME ($DETECTED_TYPE)"
1339
+ echo "[DRY-RUN] Architecture: ${ARCH_LABEL:-$DETECTED_ARCH}"
315
1340
  echo "[DRY-RUN] Selected Mode: ${MODE:-standalone}"
316
1341
  echo "[DRY-RUN] Port: $PORT"
317
1342
  echo "[DRY-RUN] TLS: $TLS"
318
- if [ -n "$DOMAIN" ]; then echo "[DRY-RUN] Domain: $DOMAIN"; fi
1343
+ if [ -n "$GATEWAY" ]; then echo "[DRY-RUN] Domain: $GATEWAY"; fi
319
1344
  if [ -n "$USERNAME" ]; then
320
1345
  echo "[DRY-RUN] User: $USERNAME"
321
- echo "[DRY-RUN] Canonical MCP URL: $(node_sse_url "$USERNAME" "${TOKEN:-<token>}")"
1346
+ if [ -n "$GATEWAY" ]; then
1347
+ echo "[DRY-RUN] Canonical MCP URL: $(node_sse_url "$USERNAME" "${TOKEN:-<token>}")"
1348
+ fi
322
1349
  fi
323
1350
  if [ -n "$TOKEN" ]; then echo "[DRY-RUN] Token: set (standalone: stored in ${CONFIG_DIR}/standalone.env, chmod 600)"; fi
324
- echo "[DRY-RUN] Systemd service and dependencies check: OK"
1351
+ if [ "$IS_TERMUX" = true ]; then
1352
+ echo "[DRY-RUN] Platform: Termux (Android)"
1353
+ echo "[DRY-RUN] Termux prefix: $TERMUX_PREFIX"
1354
+ echo "[DRY-RUN] Package manager: pkg (sudo, systemd and uv do not exist on Android)"
1355
+ echo "[DRY-RUN] Autostart: runit service <name> + ~/.termux/boot/<name>.sh (Termux:Boot)"
1356
+ echo "[DRY-RUN] Domain file: $DEFAULT_DOMAIN_FILE"
1357
+ else
1358
+ echo "[DRY-RUN] Domain file: $DEFAULT_DOMAIN_FILE"
1359
+ echo "[DRY-RUN] Systemd service and dependencies check: OK"
1360
+ fi
1361
+ if [ -z "$GATEWAY" ]; then
1362
+ echo "[DRY-RUN] Domain file value: not written (no domain configured)"
1363
+ elif [ "$GATEWAY" = "$(normalise_host "$(module_default_domain)")" ]; then
1364
+ echo "[DRY-RUN] Domain file value: not written (the built-in default is a fallback, not a configuration)"
1365
+ else
1366
+ echo "[DRY-RUN] Domain file value: MESH_PUBLIC_URL=https://$GATEWAY"
1367
+ fi
325
1368
  exit 0
326
1369
  fi
327
1370
 
@@ -348,7 +1391,15 @@ if [ -n "$SSH_TARGET" ]; then
348
1391
  if [ -d "$SCRIPT_DIR/skills" ]; then
349
1392
  $SCP_BIN -P "$SSH_PORT" -r "$SCRIPT_DIR/skills"/* "$SSH_TARGET:~/antigravity-mesh/skills/" 2>/dev/null || true
350
1393
  fi
351
- $SSH_BIN -p "$SSH_PORT" "$SSH_TARGET" "cd ~/antigravity-mesh && bash install.sh --quick --lang=$LANG_CHOICE"
1394
+ # The domain was resolved on THIS machine, so it is handed to the target: the
1395
+ # remote copy otherwise re-resolves from its own environment, and a repository
1396
+ # copy whose $PUBLISHED_DOMAIN is still the placeholder falls back to the
1397
+ # built-in default - which the target would now also record as its domain.
1398
+ REMOTE_DOMAIN_ARG=""
1399
+ if [ -n "$GATEWAY" ] && [ "$GATEWAY" != "$DOMAIN_PLACEHOLDER" ]; then
1400
+ REMOTE_DOMAIN_ARG=" --domain=$GATEWAY"
1401
+ fi
1402
+ $SSH_BIN -p "$SSH_PORT" "$SSH_TARGET" "cd ~/antigravity-mesh && bash install.sh --quick --lang=$LANG_CHOICE$REMOTE_DOMAIN_ARG"
352
1403
  exit 0
353
1404
  fi
354
1405
 
@@ -439,7 +1490,12 @@ if [ "$MODE" = "standalone" ]; then
439
1490
  echo "[1/3] Checking Python environment..."
440
1491
  fi
441
1492
  ensure_dependencies
1493
+ # The endpoint check at the end of this branch runs through curl.
1494
+ ensure_curl || exit 1
442
1495
  mkdir -p "$CONFIG_DIR"
1496
+ # A standalone node answers share_file too, so the domain it resolved is written
1497
+ # down here as well - before the server is started, not after.
1498
+ write_domain_file "$GATEWAY"
443
1499
 
444
1500
  # Honour --token in standalone mode without embedding the secret in the
445
1501
  # (world-readable) unit/plist: keep it in a 0600 env file for systemd
@@ -473,7 +1529,11 @@ if [ "$MODE" = "standalone" ]; then
473
1529
  fi
474
1530
 
475
1531
  if [ "$LANG_CHOICE" = "ru" ]; then
476
- echo "[2/3] Настройка systemd автозапуска на ПК..."
1532
+ if [ "$IS_TERMUX" = true ]; then
1533
+ echo "[2/3] Настройка автозапуска на телефоне (runit + Termux:Boot)..."
1534
+ else
1535
+ echo "[2/3] Настройка systemd автозапуска на ПК..."
1536
+ fi
477
1537
  else
478
1538
  echo "[2/3] Configuring autostart background service..."
479
1539
  fi
@@ -491,7 +1551,7 @@ if [ "$MODE" = "standalone" ]; then
491
1551
  <string>com.antigravity.mesh.standalone</string>
492
1552
  <key>ProgramArguments</key>
493
1553
  <array>
494
- <string>$(which python3)</string>
1554
+ <string>$PYTHON_BIN</string>
495
1555
  <string>-m</string>
496
1556
  <string>core.server</string>
497
1557
  <string>--host=127.0.0.1</string>
@@ -512,6 +1572,10 @@ EOF
512
1572
  fi
513
1573
  launchctl unload "$PLIST_FILE" 2>/dev/null || true
514
1574
  launchctl load -w "$PLIST_FILE" 2>/dev/null || true
1575
+ elif [ "$IS_TERMUX" = true ]; then
1576
+ # Android: neither systemd nor launchd exists - runit inside Termux
1577
+ # supervises the server, and Termux:Boot brings it back after a reboot.
1578
+ configure_termux_node "agy-standalone" "core.server" "--host 127.0.0.1 --port=$PORT" "$STANDALONE_ENV_FILE"
515
1579
  else
516
1580
  USER_SYSTEMD_DIR="$HOME/.config/systemd/user"
517
1581
  mkdir -p "$USER_SYSTEMD_DIR"
@@ -562,12 +1626,26 @@ EOF
562
1626
  if [ "$HEALTH_OK" != true ]; then
563
1627
  if [ "$LANG_CHOICE" = "ru" ]; then
564
1628
  echo -e "\033[1;31m[ОШИБКА] Standalone MCP-сервер не отвечает (HTTP ${HTTP_CODE:-нет ответа}): $HEALTH_URL\033[0m" >&2
565
- echo -e " Проверьте статус: ${CYAN}systemctl --user status agy-standalone.service${RESET}" >&2
566
- echo -e " Логи: ${CYAN}journalctl --user -u agy-standalone.service -n 50 --no-pager${RESET}" >&2
1629
+ if [ "$IS_TERMUX" = true ]; then
1630
+ echo -e " Статус: ${CYAN}sv status agy-standalone${RESET} (runit, termux-services)" >&2
1631
+ echo -e " Логи: ${CYAN}$TERMUX_PREFIX/var/log/sv/agy-standalone/current${RESET}" >&2
1632
+ echo -e " Локальный сервер на телефоне доступен только внутри Termux (127.0.0.1):" >&2
1633
+ echo -e " для Gemini с телефона используйте режим туннеля (--mode=tunnel)." >&2
1634
+ else
1635
+ echo -e " Проверьте статус: ${CYAN}systemctl --user status agy-standalone.service${RESET}" >&2
1636
+ echo -e " Логи: ${CYAN}journalctl --user -u agy-standalone.service -n 50 --no-pager${RESET}" >&2
1637
+ fi
567
1638
  else
568
1639
  echo -e "\033[1;31m[ERROR] Standalone MCP server is not responding (HTTP ${HTTP_CODE:-no response}): $HEALTH_URL\033[0m" >&2
569
- echo -e " Check status: ${CYAN}systemctl --user status agy-standalone.service${RESET}" >&2
570
- echo -e " Logs: ${CYAN}journalctl --user -u agy-standalone.service -n 50 --no-pager${RESET}" >&2
1640
+ if [ "$IS_TERMUX" = true ]; then
1641
+ echo -e " Status: ${CYAN}sv status agy-standalone${RESET} (runit, termux-services)" >&2
1642
+ echo -e " Logs: ${CYAN}$TERMUX_PREFIX/var/log/sv/agy-standalone/current${RESET}" >&2
1643
+ echo -e " A local server on a phone is reachable inside Termux only (127.0.0.1):" >&2
1644
+ echo -e " use --mode=tunnel to drive this phone from Gemini." >&2
1645
+ else
1646
+ echo -e " Check status: ${CYAN}systemctl --user status agy-standalone.service${RESET}" >&2
1647
+ echo -e " Logs: ${CYAN}journalctl --user -u agy-standalone.service -n 50 --no-pager${RESET}" >&2
1648
+ fi
571
1649
  fi
572
1650
  exit 1
573
1651
  fi
@@ -578,7 +1656,13 @@ EOF
578
1656
  COPIED=false
579
1657
  B64_URL=$(printf "%s" "$MCP_LOCAL_URL" | base64 | tr -d '\r\n')
580
1658
  printf "\033]52;c;%s\a" "$B64_URL" 2>/dev/null || true
581
- if command -v wl-copy >/dev/null 2>&1; then
1659
+ if command -v termux-clipboard-set >/dev/null 2>&1; then
1660
+ # Termux on a phone: termux-api plus the Termux:API app put the URL on
1661
+ # Android's clipboard, where the Gemini app can paste it. Without the app
1662
+ # the command exists but fails, so COPIED stays false and the hint below
1663
+ # tells the user to copy by hand.
1664
+ printf "%s" "$MCP_LOCAL_URL" | termux-clipboard-set 2>/dev/null && COPIED=true
1665
+ elif command -v wl-copy >/dev/null 2>&1; then
582
1666
  printf "%s" "$MCP_LOCAL_URL" | wl-copy 2>/dev/null && COPIED=true
583
1667
  elif command -v xclip >/dev/null 2>&1; then
584
1668
  printf "%s" "$MCP_LOCAL_URL" | xclip -selection clipboard 2>/dev/null && COPIED=true
@@ -623,6 +1707,9 @@ EOF
623
1707
  echo -e " 3. Paste the URL into the ${BOLD}'Server URL'${RESET} field (${BOLD}Ctrl+V${RESET}) and click ${BOLD}Connect${RESET}."
624
1708
  echo ""
625
1709
  fi
1710
+ if [ "$IS_TERMUX" = true ]; then
1711
+ print_termux_hints "agy-standalone"
1712
+ fi
626
1713
  exit 0
627
1714
  fi
628
1715
 
@@ -651,6 +1738,14 @@ else
651
1738
  fi
652
1739
 
653
1740
  ensure_dependencies
1741
+ # Registration - the step right after this - is a curl POST. A machine with a usable
1742
+ # Python and no curl (a minimal WSL image, a slim container) used to die there with
1743
+ # "curl: command not found" and no hint about what to install.
1744
+ ensure_curl || exit 1
1745
+ # Registration is also the first step that truly needs the gateway: a name that does
1746
+ # not resolve here would be reported a moment later as "no authentication token",
1747
+ # which blames the gateway's answer instead of this device's DNS.
1748
+ require_gateway_dns || exit 1
654
1749
 
655
1750
  if [ "$LANG_CHOICE" = "ru" ]; then
656
1751
  echo "[2/4] Регистрация узла '${USERNAME}' на общем домене (${GATEWAY})..."
@@ -676,8 +1771,10 @@ else
676
1771
  fi
677
1772
 
678
1773
  DETECTED_MAC=""
679
- if command -v python3 >/dev/null 2>&1; then
680
- DETECTED_MAC=$(python3 -c "import uuid; print(':'.join(['{:02x}'.format((uuid.getnode() >> ele) & 0xff) for ele in range(0,8*6,8)][::-1]))" 2>/dev/null || true)
1774
+ # "$PYTHON_BIN", never a bare python3: after the venv or uv fallback the working
1775
+ # interpreter is not on PATH, and the bare name would silently produce no MAC.
1776
+ if python_works "$PYTHON_BIN"; then
1777
+ DETECTED_MAC=$("$PYTHON_BIN" -c "import uuid; print(':'.join(['{:02x}'.format((uuid.getnode() >> ele) & 0xff) for ele in range(0,8*6,8)][::-1]))" 2>/dev/null || true)
681
1778
  fi
682
1779
  if [ -z "$DETECTED_MAC" ] || [ "$DETECTED_MAC" = "00:00:00:00:00:00" ]; then
683
1780
  for iface in /sys/class/net/*; do
@@ -691,7 +1788,19 @@ if [ -z "$DETECTED_MAC" ] || [ "$DETECTED_MAC" = "00:00:00:00:00:00" ]; then
691
1788
  done
692
1789
  fi
693
1790
 
694
- REG_BODY="{\"username\": \"${USERNAME}\", \"auto_suffix\": true, \"mac_address\": \"${DETECTED_MAC}\", \"os\": \"${DETECTED_OS}\""
1791
+ # One field out of the registration answer. The JSON arrives on stdin rather than
1792
+ # in argv (a token or an error message can contain quotes), and "$PYTHON_BIN" is
1793
+ # used because after the venv or uv fallback there is no python3 on PATH - a bare
1794
+ # name would return an empty token, which the check below would then blame on the
1795
+ # gateway.
1796
+ json_field() {
1797
+ # tr strips the CR that a Windows python.exe appends under Git Bash/MSYS.
1798
+ # install.sh explicitly supports MINGW/MSYS/CYGWIN (see DEFAULT_DOMAIN_DIR), and
1799
+ # a trailing CR inside the token would be pasted straight into the wss:// URL.
1800
+ printf '%s' "$1" | "$PYTHON_BIN" -c 'import json, sys; print(json.load(sys.stdin).get(sys.argv[1], ""))' "$2" 2>/dev/null | tr -d '\r\n' || true
1801
+ }
1802
+
1803
+ REG_BODY="{\"username\": \"${USERNAME}\", \"auto_suffix\": true, \"mac_address\": \"${DETECTED_MAC}\", \"os\": \"${DETECTED_OS}\", \"arch\": \"${DETECTED_ARCH}\""
695
1804
  if [ -n "$REQ_TOKEN" ]; then
696
1805
  REG_BODY="${REG_BODY}, \"token\": \"${REQ_TOKEN}\""
697
1806
  fi
@@ -701,16 +1810,16 @@ REG_RESP=$(curl -s -X POST "https://${GATEWAY}/api/register" \
701
1810
  -H "Content-Type: application/json" \
702
1811
  -d "$REG_BODY")
703
1812
 
704
- ASSIGNED_USER=$(python3 -c "import json, sys; print(json.loads(sys.argv[1]).get('username', ''))" "$REG_RESP" 2>/dev/null || true)
705
- ASSIGNED_TOKEN=$(python3 -c "import json, sys; print(json.loads(sys.argv[1]).get('token', ''))" "$REG_RESP" 2>/dev/null || true)
1813
+ ASSIGNED_USER=$(json_field "$REG_RESP" username)
1814
+ ASSIGNED_TOKEN=$(json_field "$REG_RESP" token)
706
1815
 
707
1816
  if [ -z "$ASSIGNED_TOKEN" ]; then
708
1817
  FALLBACK_USER="${USERNAME}-$(date +%s | tail -c 4)"
709
1818
  REG_RESP=$(curl -s -X POST "https://${GATEWAY}/api/register" \
710
1819
  -H "Content-Type: application/json" \
711
- -d "{\"username\": \"${FALLBACK_USER}\", \"auto_suffix\": true, \"mac_address\": \"${DETECTED_MAC}\", \"os\": \"${DETECTED_OS}\"}")
712
- ASSIGNED_USER=$(python3 -c "import json, sys; print(json.loads(sys.argv[1]).get('username', ''))" "$REG_RESP" 2>/dev/null || true)
713
- ASSIGNED_TOKEN=$(python3 -c "import json, sys; print(json.loads(sys.argv[1]).get('token', ''))" "$REG_RESP" 2>/dev/null || true)
1820
+ -d "{\"username\": \"${FALLBACK_USER}\", \"auto_suffix\": true, \"mac_address\": \"${DETECTED_MAC}\", \"os\": \"${DETECTED_OS}\", \"arch\": \"${DETECTED_ARCH}\"}")
1821
+ ASSIGNED_USER=$(json_field "$REG_RESP" username)
1822
+ ASSIGNED_TOKEN=$(json_field "$REG_RESP" token)
714
1823
  fi
715
1824
 
716
1825
  if [ -z "$ASSIGNED_TOKEN" ]; then
@@ -739,8 +1848,17 @@ MESH_TOKEN=${ASSIGNED_TOKEN}
739
1848
  EOF
740
1849
  chmod 600 "$CONFIG_FILE"
741
1850
 
1851
+ # agent.env carries only the legacy MESH_GATEWAY, which core/domain.py honours for
1852
+ # the tunnel but never for the links a node publishes. Writing the domain down is
1853
+ # what keeps share links on the gateway this node is registered on.
1854
+ write_domain_file "$GATEWAY"
1855
+
742
1856
  if [ "$LANG_CHOICE" = "ru" ]; then
743
- echo "[4/4] Настройка и запуск службы автозапуска на ПК..."
1857
+ if [ "$IS_TERMUX" = true ]; then
1858
+ echo "[4/4] Настройка и запуск автозапуска на телефоне (runit + Termux:Boot)..."
1859
+ else
1860
+ echo "[4/4] Настройка и запуск службы автозапуска на ПК..."
1861
+ fi
744
1862
  else
745
1863
  echo "[4/4] Configuring and starting background autostart service..."
746
1864
  fi
@@ -758,7 +1876,7 @@ if [ "$(uname -s)" = "Darwin" ]; then
758
1876
  <string>com.antigravity.mesh.agent</string>
759
1877
  <key>ProgramArguments</key>
760
1878
  <array>
761
- <string>$(which python3)</string>
1879
+ <string>$PYTHON_BIN</string>
762
1880
  <string>-m</string>
763
1881
  <string>core.agent</string>
764
1882
  </array>
@@ -782,6 +1900,10 @@ if [ "$(uname -s)" = "Darwin" ]; then
782
1900
  EOF
783
1901
  launchctl unload "$PLIST_FILE" 2>/dev/null || true
784
1902
  launchctl load -w "$PLIST_FILE" 2>/dev/null || true
1903
+ elif [ "$IS_TERMUX" = true ]; then
1904
+ # Android: the tunnel node is supervised by runit inside Termux, and the
1905
+ # Termux:Boot entry brings it back after a reboot - see configure_termux_node.
1906
+ configure_termux_node "agy-agent" "core.agent" "" "$CONFIG_FILE"
785
1907
  else
786
1908
  USER_SYSTEMD_DIR="$HOME/.config/systemd/user"
787
1909
  mkdir -p "$USER_SYSTEMD_DIR"
@@ -818,7 +1940,12 @@ MCP_URL="$(node_sse_url "$ASSIGNED_USER" "$ASSIGNED_TOKEN")"
818
1940
  COPIED=false
819
1941
  B64_URL=$(printf "%s" "$MCP_URL" | base64 | tr -d '\r\n')
820
1942
  printf "\033]52;c;%s\a" "$B64_URL" 2>/dev/null || true
821
- if command -v wl-copy >/dev/null 2>&1; then
1943
+ if command -v termux-clipboard-set >/dev/null 2>&1; then
1944
+ # Termux on a phone: termux-api plus the Termux:API app put the URL on
1945
+ # Android's clipboard, where the Gemini app can paste it. Without the app the
1946
+ # command exists but fails, so COPIED stays false and the hint says so.
1947
+ printf "%s" "$MCP_URL" | termux-clipboard-set 2>/dev/null && COPIED=true
1948
+ elif command -v wl-copy >/dev/null 2>&1; then
822
1949
  printf "%s" "$MCP_URL" | wl-copy 2>/dev/null && COPIED=true
823
1950
  elif command -v xclip >/dev/null 2>&1; then
824
1951
  printf "%s" "$MCP_URL" | xclip -selection clipboard 2>/dev/null && COPIED=true
@@ -863,3 +1990,9 @@ else
863
1990
  echo -e " 3. Paste the URL into the ${BOLD}'Server URL'${RESET} field (${BOLD}Ctrl+V${RESET}) and click ${BOLD}Connect${RESET}."
864
1991
  echo ""
865
1992
  fi
1993
+
1994
+ # The phone-specific steps (Termux:Boot, battery optimisation, clipboard) are the
1995
+ # same in both modes; on a Linux/macOS/Windows node there is nothing to add.
1996
+ if [ "$IS_TERMUX" = true ]; then
1997
+ print_termux_hints "agy-agent"
1998
+ fi