Skip to content

Instantly share code, notes, and snippets.

@GGPrompts
Last active March 11, 2026 18:03
Show Gist options
  • Select an option

  • Save GGPrompts/73bcc5b9d22c71d6ea926ca609f43823 to your computer and use it in GitHub Desktop.

Select an option

Save GGPrompts/73bcc5b9d22c71d6ea926ca609f43823 to your computer and use it in GitHub Desktop.
Termux Fixes: Running AI CLI Tools (Claude Code, Codex) on Android

Termux Fixes: Running AI CLI Tools on Android

Hard-won fixes for running Claude Code, OpenAI Codex, GitHub Copilot, and Rust PTY apps on Termux/Android.


1. Making portable-pty Work on Android/Termux

The Problem

Rust's portable-pty crate depends on the termios crate for PTY operations. The termios crate (v0.2.2) only recognizes target_os = "linux", but Rust on Android reports target_os = "android". Since Android uses identical termios structs and POSIX PTY syscalls to Linux, this is purely a target detection issue — not a compatibility one.

The Fix: 2 Changes

1. Patch the termios crate (the critical fix)

Create a local fork of termios and modify src/os/mod.rs:

// BEFORE (original termios 0.2.2)
#[cfg(target_os = "linux")] pub use self::linux as target;
#[cfg(target_os = "linux")] pub mod linux;

// AFTER (with Android support)
#[cfg(any(target_os = "linux", target_os = "android"))] pub use self::linux as target;
#[cfg(any(target_os = "linux", target_os = "android"))] pub mod linux;

Then in your project's Cargo.toml, patch the dependency:

[patch.crates-io]
termios = { path = "termios-patch" }

That's it. portable-pty's native_pty_system() now works on Android.

2. Fix hardcoded /tmp paths

Termux doesn't have access to /tmp. Replace all hardcoded /tmp references with $TMPDIR:

Rust:

fn tmp_dir() -> std::path::PathBuf {
    std::env::var("TMPDIR")
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|_| std::path::PathBuf::from("/tmp"))
}

// Use: tmp_dir().join("myapp.log") instead of "/tmp/myapp.log"

Shell scripts:

STATE_DIR="${TMPDIR:-/tmp}/my-state"
LOG_FILE="${TMPDIR:-/tmp}/myapp.log"

Why This Works

  • Android's kernel exposes standard POSIX PTY syscalls (openpty, forkpty, etc.)
  • The termios struct layout is identical between Linux and Android
  • All FFI calls in portable-pty resolve to libc functions available on both platforms
  • The only barrier was Rust's #[cfg(target_os)] not matching "android"

Architecture

Android App → WebSocket → Rust/Axum Backend → [Patched termios] → portable-pty → PTY → Shell/Claude Code

2. Running Claude Code with Bun on Termux

The Problem

Claude Code installs via npm and runs on Node.js, but startup is slow (~0.73s). Bun is much faster (~0.41s), but:

  1. Bun on Termux requires glibc-runner (Termux uses musl/bionic, Bun needs glibc)
  2. grun (glibc-runner's wrapper) has unquoted $@ that word-splits arguments with spaces, breaking -p "multi word prompt"
  3. Bun drops environment variables when run through glibc's ld.so
  4. Claude Code needs CLAUDE_CODE_TMPDIR since /tmp doesn't exist on Termux

The Fix: claude-fast wrapper + env-preload.js

Wrapper script (~/.bun/bin/claude-fast)

Bypass grun entirely by calling ld.so directly — this preserves argument quoting:

#!/bin/bash
export CLAUDE_CODE_TMPDIR="${TMPDIR:-/data/data/com.termux/files/usr/tmp}"
exec /data/data/com.termux/files/usr/glibc/lib/ld-linux-aarch64.so.1 \
  --library-path /data/data/com.termux/files/usr/glibc/lib \
  /data/data/com.termux/files/home/.bun/bin/buno \
  --preload /data/data/com.termux/files/home/.bun/bin/env-preload.js \
  /data/data/com.termux/files/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js \
  "$@"

Environment restoration (env-preload.js)

When Bun runs through ld.so, it loses all environment variables. This preload script restores them from /proc/self/environ:

// env-preload.js — Bun preload to restore env vars lost via ld.so
import { readFileSync } from "fs";

// Restore env vars from /proc/self/environ (null-byte separated)
try {
  const raw = readFileSync("/proc/self/environ", "utf8");
  for (const entry of raw.split("\0")) {
    const eq = entry.indexOf("=");
    if (eq > 0) {
      const key = entry.substring(0, eq);
      const val = entry.substring(eq + 1);
      if (!(key in process.env)) process.env[key] = val;
    }
  }
} catch {}

// Fix argv[0] and execPath for Claude Code's self-detection
process.argv[0] = process.env._ || "claude";
process.execPath = "/data/data/com.termux/files/home/.bun/bin/buno";

// Fix stderr.isTTY (Bun sometimes loses this through ld.so)
if (process.stderr && !process.stderr.isTTY && process.stdout.isTTY) {
  Object.defineProperty(process.stderr, "isTTY", { value: true });
}

// Set TMPDIR for Claude Code (Termux has no /tmp)
if (!process.env.CLAUDE_CODE_TMPDIR) {
  process.env.CLAUDE_CODE_TMPDIR = process.env.TMPDIR || "/data/data/com.termux/files/usr/tmp";
}

Key Details

  • Why not grun: It has exec $GR_BINARY $@ (unquoted!) which word-splits -p "multi word prompt" into -p, "multi, word, prompt"
  • Performance: Bun via ld.so = 0.41s startup vs Node.js = 0.73s (44% faster per spawn)
  • Don't run claude install on Termux — it downloads Anthropic's custom Bun binary which segfaults on Android
  • Disable auto-update in ~/.claude/config.json: {"autoUpdater": {"disabled": true}}

3. Running OpenAI Codex CLI on Termux

The Problem

Codex CLI installs via npm and includes a statically-linked musl Rust binary (codex_core) at:

@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/codex/codex

The binary starts, shows the session header, but every API call fails with:

error sending request for url (https://chatgpt.com/backend-api/codex/responses)

Meanwhile, curl https://chatgpt.com works fine from the same Termux session.

Root Cause: musl DNS Resolution

Statically-linked musl hardcodes DNS resolution via /etc/resolv.conf. On Android/Termux:

  • /etc/ is a read-only system partition
  • There is no /etc/resolv.conf
  • musl falls back to querying 127.0.0.1:53 — no DNS server there
  • Every hostname lookup silently fails → connection timeouts

curl works because Termux's curl uses Android's native DNS resolver, not musl's.

The Fix: proot bind-mount

Use proot to fake /etc/resolv.conf for the musl binary:

# Create a resolv.conf in your home directory
printf 'nameserver 8.8.8.8\nnameserver 1.1.1.1\n' > ~/resolv.conf

Wrapper script (~/.bun/bin/codex-fast)

#!/bin/bash
# codex-fast: wrapper for Codex CLI on Termux
# Fixes DNS resolution for statically-linked musl binary via proot
# and sets SSL cert path for Termux

RESOLV_CONF="$HOME/resolv.conf"

# Ensure resolv.conf exists
if [ ! -f "$RESOLV_CONF" ]; then
  printf 'nameserver 8.8.8.8\nnameserver 1.1.1.1\n' > "$RESOLV_CONF"
fi

export SSL_CERT_FILE=/data/data/com.termux/files/usr/etc/tls/cert.pem
exec proot -b "$RESOLV_CONF:/etc/resolv.conf" codex "$@"

Auth Setup

Codex uses ChatGPT OAuth (not OpenAI API keys). Auth on Termux's browser doesn't work, so copy credentials from desktop:

# On desktop: login first
codex login

# Copy auth to phone (via SSH/Tailscale)
cat ~/.codex/auth.json | base64 | ssh phone-ip -p 8022 'base64 -d > ~/.codex/auth.json && chmod 600 ~/.codex/auth.json'

# Phone config (~/.codex/config.toml)
sandbox_mode = "danger-full-access"
cli_auth_credentials_store = "file"

[projects."/data/data/com.termux/files/home"]
trust_level = "trusted"

Usage

# Install proot if not present
pkg install proot

# Non-interactive mode (like claude -p)
codex-fast exec "What is 2+2?"

# Without trust config, add --skip-git-repo-check
codex-fast exec --skip-git-repo-check "prompt here"

Gotchas

  • Codex eats Ctrl+C during connection retries — use killall codex from another session
  • Auth tokens expire ~10 days — re-run codex login on desktop and re-copy
  • The failed to refresh available models: timeout errors are non-fatal (model list fetching)
  • Codex uses chatgpt.com/backend-api/codex/responses (ChatGPT Plus subscription), NOT api.openai.com

4. Running GitHub Copilot CLI on Termux

The Problem

GitHub Copilot CLI (@github/copilot v1.0.3) installs via npm and works on Node.js, but crashes immediately:

Error: Cannot find module './pty.node'

Node.js native PTY modules have never worked on Termux/Android. There are no android-arm64 builds of pty.node or node-pty, and the linux-arm64 builds are glibc-linked so they can't be dlopen()'d from Termux's bionic Node.js. This is the same fundamental problem that led to Section 1's approach — using Rust's portable-pty (with the patched termios crate) instead of Node.js PTY bindings.

Root Cause

The loadNativeModule() function in Copilot's app.js throws when it can't find a compatible pty.node. This crashes the entire CLI — even in non-interactive (-p) mode where PTY is never actually used.

The Fix: Patch loadNativeModule() to return null

Since -p mode (non-interactive prompt) doesn't need PTY, we can stub out the native module loader:

# Find Copilot's app.js
APP_JS=$(node -e "console.log(require.resolve('@github/copilot/dist/app.js'))")

# Back it up first (re-apply after copilot updates)
cp "$APP_JS" "${APP_JS}.bak"

Edit app.js and find the loadNativeModule function. Replace its body so it returns a null stub instead of throwing:

// BEFORE (original)
function loadNativeModule() {
  // ... platform detection logic ...
  // throws if no compatible binary found
}

// AFTER (patched for Termux)
function loadNativeModule() {
  return { dir: null, module: null };
}

The rest of the CLI handles module: null gracefully — it just skips PTY allocation and uses stdio instead, which is exactly what -p mode needs.

Auth Setup

Copilot CLI uses GitHub authentication. The easiest approach on Termux is to reuse gh CLI credentials:

# Install gh CLI if not present
pkg install gh

# Login once
gh auth login

# Use with Copilot — pass the token via env var
GITHUB_TOKEN=$(gh auth token) copilot -sp "your prompt"

Usage

# Install
npm install -g @github/copilot

# Apply the patch (see above)

# Non-interactive mode: -s (silent/response only) -p (prompt)
GITHUB_TOKEN=$(gh auth token) copilot -sp "Explain how DNS works"

# You can alias it
alias copilot-fast='GITHUB_TOKEN=$(gh auth token) copilot -sp'
copilot-fast "What does this error mean?"

Gotchas

  • Re-apply patch after updates: copilot update overwrites app.js — keep the .bak and re-patch
  • No interactive mode: The PTY stub means interactive terminal features won't work — only -p (prompt) mode
  • GitHub auth required: Needs an active GitHub Copilot subscription (Individual, Business, or Enterprise)
  • -s flag: Silent mode — suppresses spinner/progress and returns only the response text

General Termux Tips for CLI Tools

  • No /tmp: Always use $TMPDIR (usually /data/data/com.termux/files/usr/tmp)
  • No /etc/resolv.conf: Statically-linked binaries (musl, Go) that read this will fail DNS. Fix with proot bind-mount.
  • SSL certs: Termux keeps them at $PREFIX/etc/tls/cert.pem. Set SSL_CERT_FILE for tools that don't find them.
  • glibc binaries: Use glibc-runner package, but call ld.so directly if you need proper argument quoting.
  • Read-only /etc: Use proot -b to bind-mount files into read-only paths.
  • Native module failures: If a Node.js CLI crashes loading a .node binary, check if the feature it provides is actually needed for your use case. Often you can stub it out for non-interactive modes.

Project

Discovered while building CodeFactory — a mobile-first terminal/IDE app running in Termux with xterm.js frontend and Rust backend.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment