diff --git a/CHANGELOG.md b/CHANGELOG.md index 336bdf5c..fd5c54f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1240,3 +1240,8 @@ First versioned/packaged release. Installable as a Claude Code **plugin** (`/plu section on an exclusive flock (#970). A dependency build longer than one tick no longer accumulates concurrent `start.sh`/`dependency_bootstrap` runs (cargo "Text file busy"); a tick that finds a start in flight skips. +- Termux/Android Rust toolchain is now a setup-managed prerequisite (#968): + `setup.sh` installs `rust` + `rust-std-aarch64-linux-android` via `pkg` (or + fails loudly with the exact line), and `dependency_bootstrap.py` warns + upfront when cargo is absent and names it as the likely cause of a + hash-locked build failure instead of a maturin backtrace. diff --git a/bridge/dependency_bootstrap.py b/bridge/dependency_bootstrap.py index a57b6732..4f5a6249 100644 --- a/bridge/dependency_bootstrap.py +++ b/bridge/dependency_bootstrap.py @@ -161,6 +161,14 @@ def ensure_android_api_level( print(f"\033[90m✓ Android API level auto-detected: {sdk}\033[0m", file=stdout, flush=True) +def _is_termux_env(env: Mapping[str, str]) -> bool: + return bool(env.get("TERMUX_VERSION")) or "/com.termux/" in env.get("PREFIX", "") + + +def _cargo_available(env: Mapping[str, str]) -> bool: + return shutil.which("cargo", path=env.get("PATH")) is not None + + def _saved_fingerprint(path: Path) -> str: if not path.is_file(): return "" @@ -170,11 +178,22 @@ def _saved_fingerprint(path: Path) -> str: return "" -def _print_install_failure(mode: InstallMode, command_index: int, stdout: TextIO) -> None: +def _print_install_failure( + mode: InstallMode, command_index: int, stdout: TextIO, *, rust_missing: bool = False +) -> None: if mode is InstallMode.LOCKED and command_index == 0: print("❌ Hash-locked dependency installation failed", file=stdout, flush=True) - print(" If this host cannot install a locked artifact, retry with", file=stdout) - print(" CCC_DEPS_UNLOCKED=1 and report the platform gap.", file=stdout) + if rust_missing: + # #968: on Android/Termux a missing toolchain, not the lock, is the + # usual killer — name it instead of a maturin/rustup backtrace. + print(" Likely cause: this Android/Termux host has no Rust toolchain,", file=stdout) + print(" so packages without an Android-compatible wheel cannot build", file=stdout) + print(" (maturin needs cargo). Fix and retry:", file=stdout) + print(" pkg install rust rust-std-aarch64-linux-android", file=stdout) + print(" CCC_DEPS_UNLOCKED=1 does NOT bypass a missing toolchain.", file=stdout) + else: + print(" If this host cannot install a locked artifact, retry with", file=stdout) + print(" CCC_DEPS_UNLOCKED=1 and report the platform gap.", file=stdout) elif mode is InstallMode.UNLOCKED and command_index == 0: print("❌ Failed to upgrade pip", file=stdout, flush=True) elif mode is InstallMode.UNLOCKED and command_index == 1: @@ -206,6 +225,15 @@ def sync_dependencies( print("📦 Installing Python dependencies...", file=stdout, flush=True) child_env = dict(os.environ if environ is None else environ) ensure_android_api_level(child_env, stdout=stdout) + rust_missing = _is_termux_env(child_env) and not _cargo_available(child_env) + if rust_missing: + print( + "⚠️ Android/Termux host without a Rust toolchain — packages without " + "an Android-compatible wheel (e.g. cryptography via maturin) will fail " + "to build. Install it with: pkg install rust rust-std-aarch64-linux-android", + file=stdout, + flush=True, + ) if mode is InstallMode.LOCKED and not paths.lock.is_file(): print(f"❌ Hash lock not found: {paths.lock}", file=stdout) print(" Regenerate it with scripts/ccc-deps-lock.sh, or set", file=stdout) @@ -226,7 +254,7 @@ def sync_dependencies( _print_install_failure(mode, index, stdout) return 1 if result.returncode != 0: - _print_install_failure(mode, index, stdout) + _print_install_failure(mode, index, stdout, rust_missing=rust_missing) return 1 try: diff --git a/bridge/tests/test_deps_install_mode.py b/bridge/tests/test_deps_install_mode.py index 01828fb7..9924937c 100644 --- a/bridge/tests/test_deps_install_mode.py +++ b/bridge/tests/test_deps_install_mode.py @@ -362,5 +362,81 @@ def test_operator_android_api_level_skips_getprop(self): self.assertIn("ANDROID_API_LEVEL=34", (Path(tmpdir) / "pip-calls.log").read_text()) +class RustToolchainPreflightTests(unittest.TestCase): + """#968: Android/Termux hash-locked installs need a Rust toolchain.""" + + def _make_paths(self, root: Path, pip_exit: int) -> DependencyPaths: + bridge = root / "bridge" + bin_dir = bridge / "venv" / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + requirements = bridge / "requirements.txt" + requirements.write_text("demo==1.0\n", encoding="utf-8") + lock = bridge / "requirements.lock.txt" + lock.write_text("demo==1.0 --hash=sha256:abc\n", encoding="utf-8") + pyproject = bridge / "pyproject.toml" + pyproject.write_text("[project]\nname = 'demo'\n", encoding="utf-8") + pip = bin_dir / "pip" + pip.write_text(f"#!/bin/bash\nexit {pip_exit}\n", encoding="utf-8") + pip.chmod(0o755) + return DependencyPaths( + bridge_dir=bridge, + venv_dir=bridge / "venv", + project_env=root / "project.env", + bridge_env=root / "bridge.env", + requirements=requirements, + lock=lock, + pyproject=pyproject, + hash_cache=bridge / ".req_hash", + pip=pip, + ) + + def _run(self, paths: DependencyPaths, *, pip_exit: int, cargo: bool): + import io + + from telegram_bot.dependency_bootstrap import sync_dependencies + + with tempfile.TemporaryDirectory() as tmpdir: + fake_bin = Path(tmpdir) / "bin" + fake_bin.mkdir() + if cargo: + cargo_bin = fake_bin / "cargo" + cargo_bin.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") + cargo_bin.chmod(0o755) + environ = { + "TERMUX_VERSION": "0.118", + "PATH": f"{fake_bin}{os.pathsep}/usr/bin{os.pathsep}/bin", + } + buf = io.StringIO() + rc = sync_dependencies( + paths, InstallMode.LOCKED, force_install=True, environ=environ, stdout=buf + ) + return rc, buf.getvalue() + + def test_termux_without_cargo_warns_upfront_and_diagnoses_failure(self): + with tempfile.TemporaryDirectory() as tmpdir: + paths = self._make_paths(Path(tmpdir), pip_exit=1) + rc, out = self._run(paths, pip_exit=1, cargo=False) + self.assertEqual(rc, 1) + self.assertIn("without a Rust toolchain", out) + self.assertIn("pkg install rust rust-std-aarch64-linux-android", out) + self.assertIn("does NOT bypass a missing toolchain", out) + self.assertNotIn("report the platform gap", out) + + def test_termux_with_cargo_keeps_legacy_hint_and_skips_warning(self): + with tempfile.TemporaryDirectory() as tmpdir: + paths = self._make_paths(Path(tmpdir), pip_exit=1) + rc, out = self._run(paths, pip_exit=1, cargo=True) + self.assertEqual(rc, 1) + self.assertNotIn("without a Rust toolchain", out) + self.assertIn("CCC_DEPS_UNLOCKED=1 and report the platform gap.", out) + + def test_termux_without_cargo_warns_but_does_not_block_success(self): + with tempfile.TemporaryDirectory() as tmpdir: + paths = self._make_paths(Path(tmpdir), pip_exit=0) + rc, out = self._run(paths, pip_exit=0, cargo=False) + self.assertEqual(rc, 0) + self.assertIn("without a Rust toolchain", out) + + if __name__ == "__main__": unittest.main() diff --git a/docs/android-termux-claude.md b/docs/android-termux-claude.md index d1f1fc5d..e636b3bb 100644 --- a/docs/android-termux-claude.md +++ b/docs/android-termux-claude.md @@ -23,6 +23,21 @@ it from scratch. This repo (ccc-node) only needs the bridge-side tweaks noted below. - Avoid proot for the runtime: it works but is ~9x slower. +## Bridge dependency builds: Rust toolchain is a hard prerequisite (#968) + +Hash-locked installs (`bridge/requirements.lock.txt`) may contain packages +with **no Android wheel** — e.g. `cryptography` 50, which builds via maturin +and therefore needs **Rust**. A missing toolchain killed the `daegyo` bridge +on 2026-08-06 (restart -> lock reconcile -> maturin failure -> 4h15m outage); +`gongyung` survived only because Rust was already present. + +- `setup.sh` now installs `rust` + `rust-std-aarch64-linux-android` via `pkg` + on Termux (or prints the exact install line when it cannot). +- `dependency_bootstrap.py` warns upfront when an Android/Termux host lacks + cargo, and its install-failure message names the toolchain as the likely + cause. `CCC_DEPS_UNLOCKED=1` does **not** bypass a missing toolchain. +- Manual fix: `pkg install rust rust-std-aarch64-linux-android`. + ## Root cause — why glibc-native fails Symptom when launching the native node/claude binary: diff --git a/scripts/setup.test.sh b/scripts/setup.test.sh index 4794a75e..25529a6c 100644 --- a/scripts/setup.test.sh +++ b/scripts/setup.test.sh @@ -438,6 +438,30 @@ HOME="$lb_home" CCC_CLAUDE_DIR="$lb_claude" CCC_HERMES_DIR="$lb_hermes" \ bash "$SETUP" --no-backup >/dev/null 2>&1 ok "setup installs the versioned live-backups rotate script" \ '[ -x "$lb_home/.ccc-node/scripts/ccc-live-backups-rotate.sh" ] && grep -q "CCC_LIVE_BACKUPS_ROOTS" "$lb_home/.ccc-node/scripts/ccc-live-backups-rotate.sh"' +# #968: Termux Rust toolchain handling. +tm_home="$TMP/tm-home"; tm_claude="$TMP/tm-claude"; tm_hermes="$TMP/tm-hermes"; tm_bin="$TMP/tm-bin" +mkdir -p "$tm_bin" +printf '#!/usr/bin/env bash\necho "$@" >> "%s"\nexit 0\n' "$TMP/tm-pkg.calls" > "$tm_bin/pkg" +chmod +x "$tm_bin/pkg" +out="$(HOME="$tm_home" CCC_CLAUDE_DIR="$tm_claude" CCC_HERMES_DIR="$tm_hermes" \ + TERMUX_VERSION=0.118 PATH="$tm_bin:/usr/local/bin:/usr/bin:/bin" bash "$SETUP" --no-backup 2>&1)"; rc=$? +ok "Termux without cargo installs the Rust toolchain via pkg" \ + '[ "$rc" = 0 ] && grep -q "rust rust-std-aarch64-linux-android" "$TMP/tm-pkg.calls"' + +printf '#!/usr/bin/env bash\necho "cargo 1.97.1"\nexit 0\n' > "$tm_bin/cargo" +chmod +x "$tm_bin/cargo" +: > "$TMP/tm-pkg.calls" +out="$(HOME="$tm_home" CCC_CLAUDE_DIR="$tm_claude" CCC_HERMES_DIR="$tm_hermes" \ + TERMUX_VERSION=0.118 PATH="$tm_bin:/usr/local/bin:/usr/bin:/bin" bash "$SETUP" --no-backup 2>&1)"; rc=$? +ok "Termux with cargo skips pkg install" \ + '[ "$rc" = 0 ] && [ ! -s "$TMP/tm-pkg.calls" ] && grep -q "Rust toolchain present" <<<"$out"' + +rm -f "$tm_bin/cargo" +: > "$TMP/tm-pkg.calls" +out="$(HOME="$tm_home" CCC_CLAUDE_DIR="$tm_claude" CCC_HERMES_DIR="$tm_hermes" \ + TERMUX_VERSION=0.118 PATH="$tm_bin:/usr/local/bin:/usr/bin:/bin" bash "$SETUP" --dry-run 2>&1)"; rc=$? +ok "Termux dry-run prints but does not run pkg install" \ + 'grep -q "dry-run. pkg install -y rust rust-std-aarch64-linux-android" <<<"$out" && [ ! -s "$TMP/tm-pkg.calls" ]' echo "----"; echo "PASS=$pass FAIL=$fail" [ "$fail" = 0 ] diff --git a/setup.sh b/setup.sh index 2c986697..8756e37c 100755 --- a/setup.sh +++ b/setup.sh @@ -732,6 +732,30 @@ else fi note "Existing ccc-telegram-bridge systemd unit checked against the canonical renderer" +# #968: Termux/Android hash-locked installs may need to build packages from +# source (cryptography 50 has no Android wheel -> maturin -> Rust). A missing +# toolchain killed the daegyo bridge on 2026-08-06 and the prerequisite lived +# only in prose. Ensure it here so it is a setup-managed property; when the +# install cannot run, say so loudly with the exact pkg line. +IS_TERMUX=0 +[ -n "${TERMUX_VERSION:-}" ] && IS_TERMUX=1 +case "${PREFIX:-}" in */com.termux/*) IS_TERMUX=1 ;; esac +if [ "$IS_TERMUX" = 1 ]; then + if command -v cargo >/dev/null 2>&1; then + note "Termux Rust toolchain present ($(cargo --version 2>/dev/null | head -1))" + elif [ "$DRY" = 1 ]; then + echo "[dry-run] pkg install -y rust rust-std-aarch64-linux-android" + elif command -v pkg >/dev/null 2>&1; then + if pkg install -y rust rust-std-aarch64-linux-android; then + note "installed Termux Rust toolchain (rust + rust-std-aarch64-linux-android)" + else + note "WARNING: Rust toolchain install failed — hash-locked dependency builds (e.g. cryptography via maturin) will fail. Run: pkg install -y rust rust-std-aarch64-linux-android" + fi + else + note "WARNING: pkg not found — install the Rust toolchain manually: pkg install -y rust rust-std-aarch64-linux-android" + fi +fi + cat <<'EOF' ==> Done. Follow-up checklist (do these manually):