Hard-won fixes for running Claude Code, OpenAI Codex, GitHub Copilot, and Rust PTY apps on Termux/Android.
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.
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.
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"- 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"
Android App → WebSocket → Rust/Axum Backend → [Patched termios] → portable-pty → PTY → Shell/Claude Code
Claude Code installs via npm and runs on Node.js, but startup is slow (~0.73s). Bun is much faster (~0.41s), but:
- Bun on Termux requires
glibc-runner(Termux uses musl/bionic, Bun needs glibc) grun(glibc-runner's wrapper) has unquoted$@that word-splits arguments with spaces, breaking-p "multi word prompt"- Bun drops environment variables when run through glibc's
ld.so - Claude Code needs
CLAUDE_CODE_TMPDIRsince/tmpdoesn't exist on Termux
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 \
"$@"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";
}- Why not
grun: It hasexec $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 installon Termux — it downloads Anthropic's custom Bun binary which segfaults on Android - Disable auto-update in
~/.claude/config.json:{"autoUpdater": {"disabled": true}}
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.
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.
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#!/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 "$@"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"# 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"- Codex eats Ctrl+C during connection retries — use
killall codexfrom another session - Auth tokens expire ~10 days — re-run
codex loginon desktop and re-copy - The
failed to refresh available models: timeouterrors are non-fatal (model list fetching) - Codex uses
chatgpt.com/backend-api/codex/responses(ChatGPT Plus subscription), NOTapi.openai.com
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.
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.
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.
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"# 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?"- Re-apply patch after updates:
copilot updateoverwritesapp.js— keep the.bakand 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)
-sflag: Silent mode — suppresses spinner/progress and returns only the response text
- 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. SetSSL_CERT_FILEfor tools that don't find them. - glibc binaries: Use
glibc-runnerpackage, but callld.sodirectly if you need proper argument quoting. - Read-only
/etc: Useproot -bto bind-mount files into read-only paths. - Native module failures: If a Node.js CLI crashes loading a
.nodebinary, check if the feature it provides is actually needed for your use case. Often you can stub it out for non-interactive modes.
Discovered while building CodeFactory — a mobile-first terminal/IDE app running in Termux with xterm.js frontend and Rust backend.