fix(browser): support Termux Android scenarios (#1655)

* fix(browser): support Termux Android scenarios

* fix(build): lock Termux artifact generation

* ci(android): add manual Termux browser smoke verification

---------

Co-authored-by: tyloryang <[邮箱]>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 08:10:06 +08:00
1 parent 890dfa49f6
commit 87edf14940
11 files changed
+384 -3

No files matched your search

+57
View File
@@ -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
+29
View File
@@ -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
+16
View File
@@ -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",
+4
View File
@@ -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"
}
+16 -1
View File
@@ -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
+86
View File
@@ -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(`<!doctype html><html><body><h1>${marker}</h1></body></html>`);
});
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(`<h1>${marker}</h1>`), 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())),
);
}
+67
View File
@@ -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 ]]
+33
View File
@@ -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
+43 -1
View File
@@ -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<void>();
+21 -1
View File
@@ -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<Browser, "contexts">,
@@ -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 = () =>
+12
View File
@@ -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"),