Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
36 changes: 32 additions & 4 deletions bridge/dependency_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
76 changes: 76 additions & 0 deletions bridge/tests/test_deps_install_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
15 changes: 15 additions & 0 deletions docs/android-termux-claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions scripts/setup.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
24 changes: 24 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down