diff --git a/.github/workflows/real-termux.yml b/.github/workflows/real-termux.yml
new file mode 100644
index 0000000000..204acc1c6f
--- /dev/null
+++ b/.github/workflows/real-termux.yml
@@ -0,0 +1,57 @@
+name: Real Termux browser verification
+
+on:
+ workflow_dispatch:
+
+concurrency:
+ group: real-termux-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+env:
+ HUSKY: "0"
+
+jobs:
+ real-termux:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@v4
+ - name: Download the pinned Termux app and verify its release checksum
+ shell: bash
+ run: |
+ mkdir -p "$RUNNER_TEMP/rea-termux"
+ cd "$RUNNER_TEMP/rea-termux"
+ release=https://github.com/termux/termux-app/releases/download/v0.118.3
+ apk=termux-app_v0.118.3+github-debug_x86_64.apk
+ sums=termux-app_v0.118.3+github-debug_sha256sums
+ curl --fail --location --retry 3 --output "$apk" "$release/$apk"
+ curl --fail --location --retry 3 --output "$sums" "$release/$sums"
+ awk -v apk="$apk" '$2 == apk || $2 == "*" apk' "$sums" > selected.sha256
+ test "$(wc -l < selected.sha256)" -eq 1
+ sha256sum --check selected.sha256
+ - name: Enable emulator acceleration
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+ - name: Build and capture a page inside Termux
+ uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2
+ with:
+ api-level: 30
+ arch: x86_64
+ target: google_apis
+ ram-size: 4096M
+ heap-size: 512M
+ disk-size: 8192M
+ disable-animations: true
+ script: bash scripts/verify/termux/emulator.sh
+ - name: Upload Termux and Android diagnostics
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: termux-browser-diagnostics
+ path: ${{ runner.temp }}/rea-termux/diagnostics
+ if-no-files-found: warn
diff --git a/docs/testing.md b/docs/testing.md
index 3f91188e82..2dc5b40acf 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -801,6 +801,35 @@ separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA,
browser and managed-tool claims require their corresponding verification lanes.
Runner labels follow the [GitHub hosted-runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
+### Termux Android browser smoke test
+
+The **Real Termux browser verification** workflow is manual-only while its
+emulator setup is being validated. After the workflow reaches the default
+branch, select a revision under **Actions → Real Termux browser verification →
+Run workflow**. It boots an Android 11 (API 30) x86_64 emulator, installs the
+checksum-verified Termux 0.118.3 APK, and lets the app initialize its bootstrap.
+The host stages the selected revision using `git archive HEAD`; no workstation
+build outputs or dependencies are copied into Android.
+
+`scripts/verify/termux/emulator.sh` sends a `RUN_COMMAND` intent to Termux's app
+service. Root access is limited to staging files, sending the intent, and reading
+diagnostics; installation, compilation, Node, and Chromium run as Termux's app
+UID. The in-app script installs Termux's Android-linked Node distribution,
+checks its version against `.nvmrc`, pins npm from `packageManager`, runs
+`npm ci`, and builds with `build:termux`. Termux repository packages, including
+Chromium, are resolved at run time; their installed versions are logged.
+
+The verifier leaves Playwright's cache environment overrides unset, requires
+a healthy public MCP doctor result, and captures URL plus DOM from a loopback
+HTTP fixture through `capture-browser-scenario`. It checks completed steps,
+the exact URL, a DOM marker, and reported browser cleanup. This lane covers one
+Android emulator and Termux combination, not physical ARM devices, Electron,
+or the complete desktop browser suite. A workflow definition alone does not
+establish passing Android coverage; inspect its execution receipt.
+
+The `termux-browser-diagnostics` artifact retains the Termux command log, exit
+status, and Android logcat on success or failure. The emulator is disposable.
+
## Developer commands
Use source feedback while editing, explicit boundary checks for the changed
diff --git a/package-lock.json b/package-lock.json
index 4b668f7989..7edec6958f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -64,6 +64,7 @@
"oxlint": "1.87.0",
"turbo": "2.11.7",
"typescript": "7.0.2",
+ "typescript-termux": "npm:typescript@5.9.3",
"vitepress": "1.6.4",
"vitest": "5.0.3"
},
@@ -7367,6 +7368,21 @@
"@typescript/typescript-win32-x64": "7.0.2"
}
},
+ "node_modules/typescript-termux": {
+ "name": "typescript",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/unbash": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.12.tgz",
diff --git a/package.json b/package.json
index fbd1e08254..37eae2b939 100644
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
"precheck:ci": "npm run deps:check",
"check:ci": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint format:check knip metadata:check jscpd scan:todos && npm run verify:architecture-guards",
"build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills",
+ "build:termux": "node scripts/run-exclusive.mjs artifacts npm run build:termux:run",
+ "build:termux:run": "npm run compile:termux && npm run artifacts:skills",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"lint": "oxlint && npm run verify:module-boundaries",
@@ -239,6 +241,7 @@
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
"compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json",
+ "compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json",
"artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
@@ -312,6 +315,7 @@
"oxlint": "1.87.0",
"turbo": "2.11.7",
"typescript": "7.0.2",
+ "typescript-termux": "npm:typescript@5.9.3",
"vitepress": "1.6.4",
"vitest": "5.0.3"
}
diff --git a/scripts/rea.mjs b/scripts/rea.mjs
index 2e544487e0..769fa2fe0a 100755
--- a/scripts/rea.mjs
+++ b/scripts/rea.mjs
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { access } from "node:fs/promises";
-import { dirname, resolve } from "node:path";
+import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// Route production MCP before importing Incur. Incur owns registration helpers
@@ -15,6 +15,21 @@ const isMcpMode =
args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp");
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+// Playwright's registry assumes one of its three desktop host platforms when
+// it chooses the browser cache directory. Termux reports `android`, although
+// its Node runtime and filesystem are POSIX-compatible. Keep the cache path
+// explicit so REA can start on Termux. Browser launch still requires a
+// caller-supplied Android-compatible Chromium executable.
+if (
+ process.platform === "android" &&
+ process.env.PLAYWRIGHT_BROWSERS_PATH === undefined
+) {
+ const cacheRoot =
+ process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? ".", ".cache");
+ process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright");
+}
+
const runtimeFiles = isMcpMode
? ["dist/main.js"]
: isMcpDoctorMode
diff --git a/scripts/verify/termux/browser.mjs b/scripts/verify/termux/browser.mjs
new file mode 100644
index 0000000000..ab9dfe0d07
--- /dev/null
+++ b/scripts/verify/termux/browser.mjs
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { createServer } from "node:http";
+import { promisify } from "node:util";
+
+const execute = promisify(execFile);
+assert.equal(process.platform, "android", "Run this verifier inside Termux");
+assert.equal(process.env.PLAYWRIGHT_BROWSERS_PATH, undefined);
+// Import no browser provider here: the public dispatcher must establish its
+// Android cache fallback before Playwright initializes in each subprocess.
+const { stdout: doctorOutput } = await execute(
+ process.execPath,
+ ["scripts/rea.mjs", "mcp", "doctor", "--format", "json"],
+ { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
+);
+assert.equal(JSON.parse(doctorOutput).healthy, true, doctorOutput);
+const executable = `${process.env.PREFIX}/bin/chromium`;
+const { stdout: chromiumVersion } = await execute(executable, ["--version"], {
+ timeout: 30_000,
+});
+const marker = "rea-termux-browser-smoke";
+const server = createServer((_request, response) => {
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
+ response.end(`
${marker}
`);
+});
+await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolve);
+});
+try {
+ const url = `http://127.0.0.1:${server.address().port}/smoke`;
+ const scenario = {
+ browser: { mode: "launch", executable_path: executable },
+ start_url: { url },
+ actions: [
+ {
+ step_id: "ready",
+ action: "wait_for",
+ locator: { kind: "css", selector: "h1" },
+ state: "visible",
+ },
+ ],
+ capture: { at_end: ["url", "dom"] },
+ };
+ const { stdout } = await execute(
+ process.execPath,
+ [
+ "scripts/rea.mjs",
+ "capture-browser-scenario",
+ JSON.stringify(scenario),
+ "--json",
+ ],
+ { timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
+ );
+ const evidence = JSON.parse(stdout);
+ assert.equal(evidence.error, undefined, stdout);
+ const capture = evidence.normalized_result;
+ assert.ok(
+ capture.steps.every((step) => step.status === "completed"),
+ stdout,
+ );
+ const artifacts = capture.steps.at(-1).artifacts;
+ assert.equal(artifacts.url.state, "captured", stdout);
+ assert.equal(artifacts.url.value.url, url);
+ assert.equal(artifacts.dom.state, "captured", stdout);
+ assert.ok(artifacts.dom.value.text.includes(`${marker}
`), stdout);
+ assert.equal(capture.browser.cleanup, "terminated-owned-process");
+ console.log(
+ JSON.stringify({
+ platform: process.platform,
+ architecture: process.arch,
+ node: process.version,
+ chromium: chromiumVersion.trim(),
+ mcp_doctor: "healthy",
+ url_capture: "passed",
+ dom_capture: "passed",
+ cleanup: capture.browser.cleanup,
+ }),
+ );
+} finally {
+ await new Promise((resolve, reject) =>
+ server.close((cause) => (cause ? reject(cause) : resolve())),
+ );
+}
diff --git a/scripts/verify/termux/emulator.sh b/scripts/verify/termux/emulator.sh
new file mode 100644
index 0000000000..0d9a55e963
--- /dev/null
+++ b/scripts/verify/termux/emulator.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# Host-side orchestration. Root is used only to stage/read files and send the
+# intent; TermuxService launches all package/build/browser work as the app UID.
+set -euo pipefail
+
+staging="${RUNNER_TEMP:?}/rea-termux"
+app_home=/data/data/com.termux/files/home
+prefix=/data/data/com.termux/files/usr
+mkdir -p "$staging/diagnostics"
+collect_diagnostics() {
+ adb logcat -d > "$staging/diagnostics/logcat.txt" 2>&1 || true
+ adb exec-out cat "$app_home/rea-ci/run.log" > "$staging/diagnostics/termux.log" 2>&1 || true
+ adb exec-out cat "$app_home/rea-ci/status" > "$staging/diagnostics/status.txt" 2>&1 || true
+}
+trap collect_diagnostics EXIT
+
+adb root
+adb wait-for-device
+adb install "$staging/termux-app_v0.118.3+github-debug_x86_64.apk"
+adb shell am start -n com.termux/.app.TermuxActivity
+# First launch unpacks the APK's bundled bootstrap. Do not replace it with a
+# root-installed userland: that would miss Termux's own initialization.
+deadline=$((SECONDS + 180))
+until adb shell test -x "$prefix/bin/bash"; do
+ if (( SECONDS >= deadline )); then
+ echo 'Termux bootstrap did not finish within 180 seconds' >&2
+ exit 1
+ fi
+ sleep 2
+done
+
+git archive --format=tar HEAD > "$staging/source.tar"
+adb push "$staging/source.tar" /data/local/tmp/rea-source.tar
+adb push scripts/verify/termux/in-app.sh /data/local/tmp/rea-in-app.sh
+adb shell chmod 644 /data/local/tmp/rea-source.tar /data/local/tmp/rea-in-app.sh
+app_uid=$(adb shell stat -c %u /data/data/com.termux | tr -d '\r')
+[[ "$app_uid" =~ ^[0-9]+$ && "$app_uid" != 0 ]]
+adb shell mkdir -p "$app_home/rea-ci" "$app_home/.termux"
+adb shell "echo allow-external-apps=true > $app_home/.termux/termux.properties"
+adb shell cp /data/local/tmp/rea-in-app.sh "$app_home/rea-ci/in-app.sh"
+adb shell cp /data/local/tmp/rea-source.tar "$app_home/rea-ci/source.tar"
+adb shell chown -R "$app_uid:$app_uid" "$app_home/rea-ci" "$app_home/.termux"
+adb shell restorecon -RF "$app_home/rea-ci" "$app_home/.termux"
+# Reload the external-command policy in a fresh app process before sending it.
+adb shell am force-stop com.termux
+adb shell am start -n com.termux/.app.TermuxActivity
+# Root may send this permission-protected intent; the service executes as the
+# application, rather than running Node/Chromium through adb shell or su.
+adb shell am startservice \
+ -n com.termux/.app.RunCommandService \
+ -a com.termux.RUN_COMMAND \
+ --es com.termux.RUN_COMMAND_PATH "$prefix/bin/bash" \
+ --esa com.termux.RUN_COMMAND_ARGUMENTS "-l,$app_home/rea-ci/in-app.sh" \
+ --es com.termux.RUN_COMMAND_WORKDIR "$app_home" \
+ --ez com.termux.RUN_COMMAND_BACKGROUND true
+
+deadline=$((SECONDS + 2100))
+until adb shell test -f "$app_home/rea-ci/status"; do
+ if (( SECONDS >= deadline )); then
+ echo 'Termux verification exceeded 35 minutes' >&2
+ exit 1
+ fi
+ sleep 5
+done
+adb exec-out cat "$app_home/rea-ci/run.log"
+status=$(adb shell cat "$app_home/rea-ci/status" | tr -d '\r')
+[[ "$status" == 0 ]]
diff --git a/scripts/verify/termux/in-app.sh b/scripts/verify/termux/in-app.sh
new file mode 100644
index 0000000000..c0db638870
--- /dev/null
+++ b/scripts/verify/termux/in-app.sh
@@ -0,0 +1,33 @@
+#!/data/data/com.termux/files/usr/bin/bash
+set -euo pipefail
+
+report="$HOME/rea-ci"
+exec > "$report/run.log" 2>&1
+# Publish the result only after all commands have settled. The host reads it as
+# root; no shared-storage permission or helper Android application is needed.
+trap 'result=$?; printf "%s\n" "$result" > "$report/status"' EXIT
+set -x
+[[ "$(id -u)" != 0 ]]
+export DEBIAN_FRONTEND=noninteractive
+export HUSKY=0
+pkg update -y
+pkg upgrade -y
+pkg install -y nodejs-lts npm git x11-repo termux-exec
+pkg install -y chromium
+mkdir -p "$report/source"
+tar -xf "$report/source.tar" -C "$report/source"
+cd "$report/source"
+node -e 'if (process.platform !== "android") throw new Error("Expected Android Node.js")'
+node -e 'const expected = require("node:fs").readFileSync(".nvmrc", "utf8").trim().replace(/^v/, ""); if (process.versions.node !== expected) throw new Error(`Termux Node ${process.versions.node} differs from .nvmrc ${expected}`)'
+# Android uses Termux's Bionic-linked Node distribution; the desktop .nvmrc
+# binary cannot run here. Keep npm at the repository's exact pinned version.
+npm install --prefix "$report/toolchain" --no-package-lock --ignore-scripts \
+ "$(node -p 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).packageManager')"
+export PATH="$report/toolchain/node_modules/.bin:$PATH"
+node --version
+npm --version
+dpkg-query -W nodejs-lts chromium termux-exec
+npm ci
+npm run build:termux
+unset PLAYWRIGHT_BROWSERS_PATH npm_config_playwright_browsers_path npm_package_config_playwright_browsers_path
+node scripts/verify/termux/browser.mjs
diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts
index 40d5896a3b..79e9039751 100644
--- a/src/browser/PlaywrightScenarioBrowser.test.ts
+++ b/src/browser/PlaywrightScenarioBrowser.test.ts
@@ -8,7 +8,12 @@ import {
PlaywrightScenarioBrowserCleanupOwner,
} from "./PlaywrightScenarioBrowser.js";
-afterEach(() => vi.unstubAllEnvs());
+const hostPlatform = process.platform;
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ Object.defineProperty(process, "platform", { value: hostPlatform });
+});
it("locks one attached browser for spellings that discover the same endpoint", () => {
expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).toBe(
@@ -73,6 +78,43 @@ it("passes the selected environment to the actual browser launch boundary", asyn
});
});
+it("keeps Termux execution support when launching Chromium on Android", async () => {
+ Object.defineProperty(process, "platform", { value: "android" });
+ vi.stubEnv("PREFIX", "/data/data/com.termux/files/usr");
+ const selected = { LANG: "selected-language", PREFIX: process.env.PREFIX };
+ let launchOptions: Parameters<
+ typeof import("playwright-core").chromium.launchPersistentContext
+ >[1];
+ const scenario = browserScenarioSchema.parse({
+ browser: { mode: "launch", executable_path: process.execPath },
+ start_url: { url: "https://example.test" },
+ actions: [
+ { step_id: "settle", action: "wait_for_timeout", duration_ms: 1 },
+ ],
+ });
+
+ await expect(
+ openPlaywrightScenarioBrowser(scenario, selected, {
+ launcher: {
+ connectOverCDP: async () => {
+ throw new Error("Unexpected connection");
+ },
+ launchPersistentContext: async (_profile, options) => {
+ launchOptions = options;
+ throw new Error("launch boundary reached");
+ },
+ },
+ }),
+ ).rejects.toThrow("launch boundary reached");
+
+ expect(launchOptions?.args).toEqual(["--no-sandbox", "--single-process"]);
+ expect(launchOptions?.env).toEqual({
+ LANG: "selected-language",
+ LD_PRELOAD: "/data/data/com.termux/files/usr/lib/libtermux-exec.so",
+ TERMUX_EXEC__PROC_SELF_EXE: process.execPath,
+ });
+});
+
it("keeps attached-target ownership until cancelled cleanup actually settles", async () => {
const controller = new AbortController();
const closeStarted = deferred();
diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts
index 4cac279b63..4aa2c50fca 100644
--- a/src/browser/PlaywrightScenarioBrowser.ts
+++ b/src/browser/PlaywrightScenarioBrowser.ts
@@ -273,13 +273,26 @@ const allowedBrowserEnvironment = (
"XAUTHORITY",
"XDG_RUNTIME_DIR",
];
- return Object.fromEntries(
+ const selected = Object.fromEntries(
allowed
.map((name) => [name, environment[name]] as const)
.filter(
(entry): entry is readonly [string, string] => entry[1] !== undefined,
),
);
+ if (process.platform !== "android") return selected;
+
+ // Android rejects execve() for binaries below Termux's application data
+ // directory. Termux supplies an exec interceptor that rewrites those calls
+ // through the system linker and removes itself for the launched program.
+ // Playwright must retain it for its Chromium child process.
+ const prefix = environment.PREFIX;
+ if (prefix === undefined) return selected;
+ return {
+ ...selected,
+ LD_PRELOAD: `${prefix}/lib/libtermux-exec.so`,
+ TERMUX_EXEC__PROC_SELF_EXE: process.execPath,
+ };
};
const findConnectedPage = async (
browser: Pick,
@@ -495,6 +508,13 @@ export const openPlaywrightScenarioBrowser = async (
handleSIGINT: false,
handleSIGTERM: false,
timeout: 0,
+ // Termux's Chromium cannot create its renderer process through Android's
+ // executable boundary. Single-process mode keeps the browser and page in
+ // the Termux-launched process so scenario navigation remains available.
+ args:
+ process.platform === "android"
+ ? ["--no-sandbox", "--single-process"]
+ : [],
});
} catch (cause: unknown) {
const cleanup = () =>
diff --git a/vitest.config.ts b/vitest.config.ts
index 93ecbb64c8..099d2c2956 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -4,6 +4,18 @@ import { join } from "node:path";
import { defineConfig } from "vitest/config";
+// Playwright's registry does not derive a cache directory for Termux's
+// `android` platform. Match the production CLI fallback before workers import
+// browser modules.
+if (
+ process.platform === "android" &&
+ process.env.PLAYWRIGHT_BROWSERS_PATH === undefined
+) {
+ const cacheRoot =
+ process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? ".", ".cache");
+ process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright");
+}
+
const CANONICAL_TEMPORARY_DIRECTORY = realpathSync(tmpdir());
const COVERAGE_ENABLED = process.argv.some((argument) =>
argument.startsWith("--coverage"),