16. Garra Mobile — Termux como camada de execução (local-first no Android)
- Status: Accepted
- Deciders: @michelbr84 + Claude (sessão 2026-09-02)
- Date: 2026-09-02
- Tags: mobile, android, termux, local-first, installers, llm-providers
- Supersedes: none
- Superseded by: none
- Links:
- ROADMAP: §4.5 Garra Mobile Local (Termux → nativo)
- Antecedentes: ADR 0001 (inference backend), ADR 0015 (toolchain de pacotes/regra 15 de assets), ADR 0004 (storage),
docs/security/threat-model.md§5.6 (SSRF guard) - Implementação Fase 0: job
build-android-arm64norelease.yml; branch Termux noinstall.sh+ suítetests/install_sh/detect_platform.sh;garra doctor+ providerllamacppnogarraia-cli; assetgarraia-android-aarch64
Context and Problem Statement
O Garra existe em desktop (CLI + Tauri) e num app Flutter (apps/garraia-mobile)
que é cliente do gateway — exige um servidor rodando em algum lugar e rede.
O dono do projeto quer o Garra como assistente no próprio Android: executar
localmente no aparelho, conversar com uma LLM que pode estar (a) na nuvem, (b)
na LAN do PC/LAN-serve, ou (c) — futuramente — no próprio dispositivo. Sem
dependência de "deixar o PC ligado", sem pagar runtime de terceiros, e sem
abandonar a base Rust existente.
A pergunta arquitetural: como executar o core Rust no Android em gerações, sem reescrever Termux nem adotar um fork fragmentado do ecossistema Android?
Decision Drivers
- ★★★★★ Nunca recriar um Termux — bootstrap/compiladores/
pkg/W^X liberado é ecossistema mantido por outras pessoas; usar, não duplicar. - ★★★★★ Core agnóstico de capabilities — o core Rust nunca fala com API Android diretamente; capabilities (notificação, storage, camera) entram por camadas acima (Termux hoje, RUN_COMMAND no v1, JNI no v2, APIs nativas no v3).
- ★★★★★ Bionic dinâmico, musl proibido — musl estático quebra DNS no
Android (
/etc/resolv.confinexistente; resolver interno do musl não passa pelodnsproxyd;LD_PRELOADnão intercepta binário estático). Todo target Android usaaarch64-linux-androidvia NDK. - ★★★★ LLM externa opcional desde o dia 1 — o CLI já aceita
OpenAI-compatible em
--base-url(llama.cpp/LM Studio/vLLM/Ollama na LAN) e providers cloud; nada na Fase 0 depende de inferência no aparelho. - ★★★ Canais separados do core — Telegram/Discord/etc. são wiring do gateway; no mobile eles podem nem subir (o doctor reporta, não impede).
- ★★★ Assets aditivos (regra 15) —
garraia-android-aarch64entra ao lado dos crus existentes;garra updateresolve por nome exato.
Considered Options
| Opção | Avaliação |
|---|---|
| v0: Termux + binário bionic dedicado (escolhida) | Compila hoje sem mudança de código no core (recon 2); distribuição por curl install.sh | bash funciona de fábrica no Termux oficial (bootstrap traz curl/bash/tar/xz/sha256sum; ELF em $PREFIX/bin executa — W^X não se aplica no SDK 28); custo = um job de CI e um branch no installer. |
| Flutter app como runtime (executar LLM no app) | apps/garraia-mobile é cliente HTTP; hospedar o core dentro do Flutter exigiria FFI imediato + distribuição de binário no APK (Play proíbe download de código executável). Pula a distância toda de uma vez. |
Termux + compilar no aparelho (pkg install rust) | rustc 1.97 + rust-std-android existem no Termux, mas o phantom process killer mata builds paralelos longos; não é canal de distribuição, é fallback de emergência. |
| musl estático "universal" | Violação direta do driver 3 (DNS quebra). Proibido neste contexto. |
| PWA / WebView local | Sem execução de binário nativo; não atende "executar o core no aparelho". |
Decision Outcome
Quatro gerações, cada uma entregável isoladamente:
- v0 (Fase 0, esta ADR — release v0.3.6): binário full
aarch64-linux-android(bionic,cargo-ndk -t arm64-v8a -p 21) com openssl vendido; assetgarraia-android-aarch64; branch Termux noinstall.sh($TERMUX_VERSIONou$PREFIXcom.termux → default$PREFIX/bin, skip do preflight glibc, notice de phantom process killer/bateria);garra updateresolve o asset no Android;garra doctorcomo passo de onboarding;llamacppwired no CLI como segundo provider keyless local. - v1 — Companion Kotlin/Compose (app novo, decisão do dono): o app declara
com.termux.permission.RUN_COMMAND+<queries>com.termux; exigeallow-external-apps=true(setup guiado); stdout/exit viaRUN_COMMAND_PENDING_INTENT(cap 100KB); detecta o fork do Play do Termux (sem RUN_COMMAND) e degrada com aviso; foreground service para "Always On". O código sobrevive até o v2. - v2 — Híbrido JNI: core como
cdylibno APK via NDK; app assume capabilities do Termux (notificação, storage, location); distribuição: F-Droid (download de runtime com opt-in explícito) ou Play com jniLibs embutidos — nunca download de executável no Play. - v3 — Nativo: sem Termux; capabilities 100% Android (CameraX,
LocationManager, NotificationManager); descoberta LAN com permissões
(
NEARBY_WIFI_DEVICEShoje;ACCESS_LOCAL_NETWORKblocked-by-default targetSdk 37+ ~2027) — entrada manual de URL é UX primária desde o dia 1.
Decisões de Fase 0 e rationale
- Binário FULL com openssl vendido em vez de feature-gating channels/
telemetry: zero mudança de código no core (recon 2 confirmou que
wasmtimeeaws-sdk-s3NÃO estão no grafo do binário; os C-deps rusqlite/sqlite-vec compilam com CC/AR do NDK). Feature-gate/pinringfica para a Fase 1 como otimização — tocar no provider TLS é mudança crypto no binário desktop inteiro. garra doctor(sysexits 0/2/65,--json/--strict) como passo 2 do onboarding (install.sh→doctor→chat): sobrevive a config ausente/não-parseável, reporta plataforma (com detecção Termux), dirs, config (reuso derun_check), fonte de credencial presence-only e probe TCP vetado porgarra_common::ssrf(IpScope::AllowPrivate— o alvo local é legítimo, mas link-local/CGNAT/multicast continuam bloqueados, regra 14). Probes são diagnóstico e nunca afetam o exit code.llamacppkeyless no CLI e no gateway (http://localhost:8080): llama-server é o daemon local natural de quem já tem a LLM na LAN via OpenAI-compatible; mirroring do ollama arm mantémconfig checktruthful (lockstepprovider_key_env).
Consequences
- Positivas: execução local no Android já na v0.3.6 sem tocar no core; trilho de migração explícito que reaproveita cada camada (v0 binário → v1 app → v2 lib → v3 nativo); regra 15 intacta; o guard SSRF é o mesmo do desktop.
- Negativas / assumidas:
- OpenSSL vendido alonga o job android (minutos extras; aceitável).
- Fork do Play do Termux removeu RUN_COMMAND e tem W^X diferente — a v1 detecta e degrada; a Fase 0 não funciona nele de forma oficial (o usuário pode instalar o Termux F-Droid/GitHub ao lado).
- Phantom process killer + gestão de bateria matam processos longos em
background — o installer imprime o notice; o "Always On" real chega no
v1 (foreground service).
garra start -d(fork/setsid,cfg(unix)) funciona dentro do Termux. - Android 16/17 (LNP) restringem scan de LAN — port-probe nunca foi o
caminho (nenhum servidor LLM anuncia mDNS); URL manual +
--base-urlé a UX primária. - Sem assinatura de código (mesmo status do resto da matriz; integridade
via
SHA256SUMS). garraia-android-aarch64é best-effort (job fora doif:de gate dorelease.yml, padrão needs-mas-fora-do-if:,continue-on-errorproibido).
Amendment 2026-09-02 — distribuição e o site
O install.sh servido por garraia.org é cópia estática sincronizada do
main deste repo (regra 17): o cron de sync roda no repo do site, mas o
publish na Lovable é manual. O merge deste ADR + branch Termux não muda
o que irm https://garraia.org/install.sh serve até o publish do dono. Até
lá, o Termux resolve o script pelos mirrors diretos (GitHub release CDN,
raw, jsDelivr) documentados no README.
Amendment 2026-09-03 — o que o campo devolveu (issues #909–#913)
Cinco issues de um único usuário rodando a v0.3.6 num Samsung A16 (Android 13, Termux), com o Garra em produção orquestrado por outro agente via MCP. O retorno confirmou a v0 e corrigiu duas premissas.
rustls-platform-verifier está fora do grafo do garra, e agora sob
guarda. O reporter viu o panic conhecido do crate (Expect rustls-platform-verifier to be initialized, android.rs:90) — ele exige um
Context JNI que não existe num binário standalone. Não é o nosso binário:
cargo tree -p garraia --target aarch64-linux-android --invert rustls-platform-verifier
# error: package ID specification `rustls-platform-verifier` did not match any packages
O crate entra no Cargo.lock só via reqwest 0.13, cuja feature rustls é
ativada pelo tauri-plugin-updater do garraia-desktop — que nunca é
buildado para Android. É um acidente feliz de resolução de features, não uma
decisão, então virou uma guarda no ci.yml (job android).
Corolário que vale escrever: SSL_CERT_FILE não afeta o HTTPS do garra.
Todo o tráfego vai por reqwest 0.12 com rustls-tls, cujos roots são os do
webpki compilados no binário; nenhum trust store do sistema é lido. A única
exceção é o sqlx (native-roots), ou seja TLS de Postgres. O garra doctor
passou a dizer isso explicitamente.
O exec do Termux é problema do pai, não do processo. Hosts MCP externos
spawnam com ambiente filtrado e removem LD_PRELOAD; sem ele o exec de ELF
falha antes de o binário rodar, então nenhuma correção dentro do garraia
alcança o caso. Duas peças, em camadas diferentes:
install.shescreve$PREFIX/bin/garra-mcp-server(wrapper que exporta o shim e faz exec do CLI) — é ocommandque um host externo deve usar;McpManager::connectinjetaLD_PRELOADnos filhos MCP sobcfg(target_os = "android"), o que cobre a direção cliente (servidores npm/pip com shebang/usr/bin/env). Nunca sobrescreve um valor explícito.
Bug de cfg encontrado no caminho: apply_parent_death_signal era
#[cfg(target_os = "linux")], e target_os = "android" não é coberto por
isso em Rust — todo filho MCP no Termux ficava órfão. O bionic expõe o mesmo
prctl(PR_SET_PDEATHSIG).
Sobre distribuição: o relato "o binário de release não roda no Termux" era
sobre uma v0.3.6 que já publica garraia-android-aarch64. A causa é a do
Amendment acima — um install.sh estale servido pelo garraia.org, cujo
publish é manual. A sonda do install-endpoints.yml ganhou uma guarda de
frescor para o modo de falha que passava em todas as outras: endpoint que
existe, responde 200, parseia — e está velho.
Amendment 2026-09-04 — o exec do Termux tem dois modos de falha, não um
Segundo lote de campo do mesmo usuário (issues #920–#925, v0.3.8). A #920 é retorno direto sobre o wrapper que o Amendment anterior introduziu: ele não fechou o caso, e a razão é que "o exec falha no Termux" são na verdade duas falhas com o mesmo sintoma.
A. o host não consegue exec'ar o wrapper *script*
timeout: failed to run command 'garra-mcp-server': Permission denied
B. o wrapper roda e o exec interno do ELF falha
garra-mcp-server: 8: exec: /data/.../garraia: Permission denied
O LD_PRELOAD do Amendment anterior endereça B — e só quando o shim está
instalado. A não tem solução dentro de um wrapper, porque o wrapper também
precisa ser exec'ado: qualquer script que escrevêssemos herda a mesma falha.
Foi o próprio relator que achou o caminho que sobrevive aos dois:
/system/bin/linker64 /data/data/com.termux/files/usr/bin/garraia mcp-server
O loader dinâmico do Android mapeia o ELF direto — sem shim, sem LD_PRELOAD,
sem nenhuma variável de ambiente. Validado por ele fim a fim (handshake MCP +
garra_ask) com env -i.
Consequência para a decisão, em duas camadas com escopos honestos:
install.shescreve um segundo wrapper,garra-mcp-server-linker, que cobre B sem depender do termux-exec. Arquivo separado do primeiro, não uma linha a mais nele: a suíte afirmagrep -c '^exec '== 1 em cada um, o que faz uma futura fusão dos dois derrubar os testes em vez de apagar o fallback em silêncio.- Para A, a resposta é documental e não tem wrapper: o host aponta
command: /system/bin/linker64e passa o binário como argumento. É o quedocs/installation.md,docs/cli-mcp-server.mde ogarraia doctoragora dizem — o doctor com o caminho real preenchido.
A generalização que vale guardar: no Termux, command: apontando para
qualquer coisa nossa é frágil por construção. A única invocação que não
depende de nada do Termux é a que usa um binário do sistema Android como
argv[0]. Wrappers são conveniência para o caso comum, não a garantia.
A #925, do mesmo lote, é a contrapartida em diagnóstico: o aviso de
GARRAIA_JWT_SECRET ausente dizia o que estava errado sem dizer se importava.
Num gateway local single-user não importa — o console web, /ws,
/v1/chat/completions, o mcp-server e os canais não passam por /auth/* —,
mas /chat (mobile, via MobileAuth) e o workspace multi-tenant passam. O
aviso passa a dizer as duas coisas e o comando de correção.
Amendment 2026-09-07 — a v1 é Flutter, não um app Compose novo (Garra Mobile v0.4.0)
A decisão original previa a v1 como "companion Kotlin/Compose (app novo)". Ao executar a estratégia mobile completa (sessão 2026-09-07), a conclusão foi outra, e este amendment a registra:
Escolha: manter apps/garraia-mobile (Flutter) como a interface do
Garra Mobile e adicionar bridges nativos (Kotlin hoje, Swift depois) por
Platform Channel quando cada capability exigir. Motivo: o app já tinha
Riverpod, go_router, Dio, flutter_secure_storage, biometria, gravação de
áudio, QR, fila offline em SQLite, notificações locais e WebSocket; recomeçar
em Compose reconstruiria tudo isso e pioraria o caminho para iOS.
Alternativas consideradas: Compose novo (a decisão anterior), React
Native/Expo, Tauri Mobile — nenhuma reaproveita o que existe.
Trade-offs: algumas capabilities Android (RUN_COMMAND do Termux,
foreground service, Keystore) exigirão Kotlin via bridge; isso fica para a
v0.5.x e não muda o core.
O que a v0.4.0 entrega, e o que ela deliberadamente não entrega:
RuntimeConnection(lib/runtime/): a UI não sabe onde o Garra está. Três modos — On this phone (http://127.0.0.1:3888, Termux), Another Garra (URL manual na LAN +gateway.api_keyopcional, guardada noflutter_secure_storage) e Garra Cloud (JWT, o cliente antigo). Os dois primeiros falam a superfície/api/*do gateway; o cloud herda os reads e troca só o chat por/chat.- Negociação de capabilities: a home lê
GET /api/capabilitiese um tile cuja feature não é anunciada renderiza Unavailable em vez de tela morta. O gateway passou a anunciarmemory,learning-skills,projectsemodes(capabilities.rs::feature_flags, aditivo);automationsfica ausente de propósito porque não existe API de scheduling. - Onboarding sem login nos modos local/LAN; o gate do router passou de "tem JWT?" para "tem runtime configurado?".
- Fora: ponte Kotlin
RUN_COMMAND, pairing por token local, foreground service, capabilities de câmera/contatos/localização — a estratégia §5.1 Fase 1 continua o mapa; este é o primeiro corte utilizável.
Distribuição: o APK sai do GitHub Actions (job best-effort
build-android-apk no release.yml, asset garraia-mobile-android.apk +
.sha256, aditivo — regra 15 intacta). Assinado com os secrets
ANDROID_KEYSTORE_* quando existirem, senão com a keystore de debug do
runner (instalável; não atualiza por cima de uma instalação assinada por
outra chave — o job avisa com ::warning::). O Play não é alvo desta trilha
(ver "Google Play + Termux" na estratégia); a distribuição é Release do
GitHub, e o app não baixa executável nenhum — o runtime vem do Termux.
Descobertas técnicas que valem registro: riverpod_generator 2.6.x prende o
analyzer na linguagem 3.9 e quebra em Dart >= 3.13
(visitDotShorthandPropertyAccess) — o app migrou para Riverpod 3 /
generator 4 e abandonou custom_lint + riverpod_lint; record 5.1.2
resolvia record_web e record_platform_interface incompatíveis e quebrava
flutter build web — subiu para 6.2.1; gradle.properties fixava um
org.gradle.java.home do Windows que impedia qualquer build fora daquela
máquina.