< Back to all hacks

#07 run-proot.sh Helper Script

Infrastructure
Problem
Running proot with all bind mounts, env vars, and flags is complex and error-prone.
Solution
run-proot.sh encapsulates all proot arguments: bind mounts, kill-on-exit, link2symlink, env loading.
Lesson
Wrap complex multi-flag commands in a single script. When a tool requires 15+ flags, human memory is not reliable.

Context

Once proot-distro is installed (hack 1) and the Ubuntu rootfs is in place, actually launching proot requires a long command with many flags. Bind mounts expose host filesystems into the guest. Environment variables configure the guest shell. Special flags handle Android filesystem limitations.

Typing or remembering this command is impractical, especially over SSH on a phone keyboard. The helper script captures the exact invocation that works and makes it a single command. Other scripts (start-openclaw, restart-gw) call run-proot.sh rather than duplicating the proot flags.

This hack is marked LEGACY. The native migration on Feb 15, 2026 eliminated proot entirely. The gateway now runs directly on Termux with node22-icu and an LD_PRELOAD compat shim, removing the 6-layer stack in favor of 4 layers.

Implementation

#!/data/data/com.termux/files/usr/bin/bash
# run-proot.sh — Launch proot Ubuntu environment for OpenClaw
# Deploy to: $PREFIX/bin/run-proot.sh

set -euo pipefail

PREFIX="/data/data/com.termux/files/usr"
ROOTFS="$PREFIX/var/lib/proot-distro/installed-rootfs/ubuntu"
PROOT="$PREFIX/bin/proot"

# Load environment variables from .openclaw/env if it exists
ENV_FILE="$ROOTFS/root/.openclaw/env"
ENV_ARGS=()
if [ -f "$ENV_FILE" ]; then
  while IFS='=' read -r key value; do
    # Skip comments and blank lines
    [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
    ENV_ARGS+=("-e" "$key=$value")
  done < "$ENV_FILE"
fi

exec "$PROOT" \
  --link2symlink \
  --kill-on-exit \
  --root-id \
  --rootfs="$ROOTFS" \
  --bind=/dev \
  --bind=/proc \
  --bind=/sys \
  --bind="$PREFIX:/termux-prefix" \
  --bind=/data/data/com.termux/files/home:/termux-home \
  --cwd=/root \
  "${ENV_ARGS[@]}" \
  /usr/bin/env -i \
    HOME=/root \
    LANG=C.UTF-8 \
    TERM="$TERM" \
    PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
    /bin/bash --login "$@"

Deploy the script:

cat > $PREFIX/bin/run-proot.sh << 'SCRIPT'
#!/data/data/com.termux/files/usr/bin/bash
# ... (paste full script above)
SCRIPT
chmod +x $PREFIX/bin/run-proot.sh

Usage from other scripts or directly:

# Interactive login shell
run-proot.sh

# Run a single command inside proot
run-proot.sh -c "node --version"

# Used by start-openclaw to launch the gateway
run-proot.sh -c "cd /root && node /root/.openclaw/gateway/dist/index.js"

Key flags explained:

  • --link2symlink: Translates hard link syscalls to symlink. Required because Android's filesystem does not reliably support hard links from proot userspace. Without it, npm install fails when hardlinking files in node_modules.
  • --kill-on-exit: When the proot parent dies (OOM kill, SSH disconnect), all child processes inside the guest are also killed. Prevents orphaned Node.js processes from consuming RAM silently.
  • --root-id: Makes the process appear as UID 0 inside the guest. Without it, apt and npm complain about permission errors writing to /usr/ and /root/.
  • --bind=/dev, --bind=/proc, --bind=/sys: Expose kernel interfaces. Node.js needs /proc/self/ for process introspection, and some npm packages read /sys/ for hardware info.
  • --bind=$PREFIX:/termux-prefix: Makes Termux binaries (git, curl, etc.) accessible from inside proot at a predictable path.

Verification

  • run-proot.sh drops into a bash shell with whoami returning root.
  • ls /termux-prefix/bin/ shows Termux binaries accessible from inside proot.
  • ls /termux-home/ shows the Termux home directory.
  • echo $HOME returns /root, echo $LANG returns C.UTF-8.
  • echo $PATH shows only Linux paths, no Termux paths leaked.
  • run-proot.sh -c "cat /root/.openclaw/env" shows the loaded API keys (confirms env loading works).
  • run-proot.sh -c "ls /proc/self/status" succeeds (confirms /proc is bound).

Gotchas

  • The env -i invocation strips any leaked Termux environment variables. Without it, $PATH inside proot might include Termux paths like $PREFIX/bin, causing the wrong binaries to be found (e.g., Termux's old Node v12 instead of proot's Node v22).
  • The env file at $ROOTFS/root/.openclaw/env must use KEY=VALUE format with no spaces around =. Quoted values like KEY="value with spaces" are passed literally including the quotes. Do not use export prefixes in the file.
  • The --bind=/dev mount is effectively read-only from proot's perspective. Writing to /dev/ inside the guest does not affect the host device nodes.
  • On the Moto E2 with 1 GB RAM, proot itself adds roughly 10-15 MB of RSS overhead per process due to syscall interception. Every filesystem operation goes through proot's ptrace-based translation. This overhead was one of the key motivations for the native migration.
  • If the ENV_ARGS array is empty (no env file), the "${ENV_ARGS[@]}" expansion produces nothing, which is correct. But if set -u catches an unbound variable, add a fallback: "${ENV_ARGS[@]+"${ENV_ARGS[@]}"}".
  • The --cwd=/root flag sets the initial working directory. If /root does not exist in the rootfs (corrupted install), proot silently falls back to /, which can break scripts that assume they start in $HOME.

Result

A single run-proot.sh command launches a correctly configured proot Ubuntu environment with all bind mounts, environment variables, and compatibility flags. Other scripts call it as a building block, avoiding duplicated proot flags across the codebase. The script was the backbone of the proot-based stack until the native migration made it obsolete, reducing the stack from 6 layers to 4 and eliminating the per-syscall proot overhead.