Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

518 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* boot-validate.mjs — Validate that a booted brand-AOSP device is healthy.
*
* Brand-aware. Reads brand config (package name, app name, classPrefix,
* envPrefix, productName) and runs a series of adb checks:
* - Boot properties (ro.<envPrefix>.product, <envPrefix>.boot_phase, …)
* - Package install path (/system/priv-app/<AppName>/)
* - HOME / DIALER / SMS / ASSISTANT role holders == brand package
* - Replacement intent resolutions for stripped stock apps
* - Forbidden stock packages absent
* - Granted permissions and appops
* - Logcat scrubbed for brand-introduced FATAL / SecurityException / avc denied
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { loadBrandFromArgv } from "./brand-config.mjs";
const REQUIRED_ROLES = [
"android.app.role.HOME",
"android.app.role.DIALER",
"android.app.role.SMS",
"android.app.role.ASSISTANT",
];
const REQUIRED_GRANTED_PERMISSIONS = [
"android.permission.READ_CONTACTS",
"android.permission.WRITE_CONTACTS",
"android.permission.CALL_PHONE",
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS",
"android.permission.READ_CALL_LOG",
"android.permission.WRITE_CALL_LOG",
"android.permission.READ_SMS",
"android.permission.SEND_SMS",
"android.permission.RECEIVE_SMS",
"android.permission.RECEIVE_MMS",
"android.permission.RECEIVE_WAP_PUSH",
"android.permission.POST_NOTIFICATIONS",
];
const FORBIDDEN_STOCK_PACKAGES = [
"com.android.browser",
"com.android.calendar",
"com.android.camera2",
"com.android.contacts",
"com.android.deskclock",
"com.android.dialer",
"com.android.email",
"com.android.gallery3d",
"com.android.launcher3",
"com.android.managedprovisioning",
"com.android.messaging",
"com.android.music",
"com.android.provision",
"com.google.android.apps.messaging",
"com.google.android.apps.nexuslauncher",
"com.google.android.dialer",
"com.google.android.setupwizard",
"org.lineageos.trebuchet",
];
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Build the brand-scoped logcat failure patterns. Stock AOSP cuttlefish
* has its own boot noise that does not indicate a problem with this
* brand — broad pattern matching there just creates false positives.
* We scope each pattern to the brand's blast radius.
*/
function logcatFailurePatterns(brand) {
const pkg = escapeRegExp(brand.packageName);
const cls = escapeRegExp(brand.classPrefix);
const lower = escapeRegExp(brand.brand);
return [
new RegExp(`FATAL EXCEPTION[^\\n]*${pkg}`, "i"),
new RegExp(`Process: ${pkg}`, "i"),
new RegExp(`SecurityException[^\\n]*(${cls}|${pkg})`, "i"),
new RegExp(`${cls}[A-Za-z]*Receiver[^\\n]*SecurityException`, "i"),
new RegExp(
`avc:\\s+denied[^\\n]*(scontext|tcontext)=u:[a-z_]*:${lower}`,
"i",
),
/privapp-permissions/i,
/Privileged permission.*not in privapp-permissions/i,
];
}
function brandBootProperties(brand) {
// <propertyPrefix>.boot_phase is intentionally non-ro so init.<brand>.rc
// can re-set it at each phase. ro.* is immutable after first set.
return {
"ro.setupwizard.mode": "DISABLED",
[`${brand.propertyPrefix}.boot_phase`]: "completed",
};
}
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../..");
export function parseSubArgs(argv) {
const args = {
adb: process.env.ADB || null,
serial: process.env.ANDROID_SERIAL || null,
timeoutMs: 180_000,
json: false,
skipLogcat: false,
expectedAbi: null,
};
const readFlagValue = (flag, index) => {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`${flag} requires a value`);
}
return value;
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--adb") {
args.adb = path.resolve(readFlagValue(arg, i));
i += 1;
} else if (arg === "--serial" || arg === "-s") {
args.serial = readFlagValue(arg, i);
i += 1;
} else if (arg === "--timeout-ms") {
args.timeoutMs = Number.parseInt(readFlagValue(arg, i), 10);
i += 1;
} else if (arg === "--json") {
args.json = true;
} else if (arg === "--skip-logcat") {
args.skipLogcat = true;
} else if (arg === "--expected-abi") {
args.expectedAbi = readFlagValue(arg, i);
i += 1;
} else if (arg === "-h" || arg === "--help") {
console.log(
"Usage: node packages/scripts/distro-android/boot-validate.mjs [--brand-config <PATH>] [--adb <ADB>] [--serial <SERIAL>] [--timeout-ms <MS>] [--json] [--skip-logcat] [--expected-abi <ABI>]",
);
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
throw new Error("--timeout-ms must be a positive integer");
}
return args;
}
export function resolveAdb(explicitAdb = null) {
if (explicitAdb) {
if (!fs.existsSync(explicitAdb)) {
throw new Error(`ADB does not exist: ${explicitAdb}`);
}
return explicitAdb;
}
const sdkRoots = [
process.env.ANDROID_HOME,
process.env.ANDROID_SDK_ROOT,
path.join(os.homedir(), "Library", "Android", "sdk"),
path.join(os.homedir(), "Android", "Sdk"),
].filter(Boolean);
for (const sdkRoot of sdkRoots) {
const candidate = path.join(sdkRoot, "platform-tools", "adb");
if (fs.existsSync(candidate)) return candidate;
}
const result = spawnSync("adb", ["version"], {
encoding: "utf8",
stdio: "ignore",
});
if (!result.error) return "adb";
throw new Error(
"Could not find adb. Set --adb, ADB, ANDROID_HOME, or ANDROID_SDK_ROOT.",
);
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? repoRoot,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.error) {
throw new Error(`${command} failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(" ")} failed:\n${result.stderr || result.stdout}`,
);
}
return result.stdout.trim();
}
function adbArgs(serial, args) {
return serial ? ["-s", serial, ...args] : args;
}
function runAdb(adb, serial, args) {
return run(adb, adbArgs(serial, args));
}
function shell(adb, serial, command) {
return runAdb(adb, serial, ["shell", command]);
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForBoot({ adb, serial, timeoutMs }) {
runAdb(adb, serial, ["wait-for-device"]);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const completed = shell(adb, serial, "getprop sys.boot_completed").trim();
if (completed === "1") {
shell(adb, serial, "wm dismiss-keyguard");
return;
}
await sleep(1_000);
}
throw new Error(
`Device did not report sys.boot_completed=1 within ${timeoutMs}ms`,
);
}
function assertIncludes(haystack, needle, label) {
if (!haystack.includes(needle)) {
throw new Error(`${label} does not include ${needle}`);
}
}
function assertMatches(value, pattern, label) {
if (!pattern.test(value)) {
throw new Error(`${label} did not match ${pattern}`);
}
}
/**
* When `--expected-abi` is set, assert the device's CPU architecture
* matches. Mirrors the riscv64 cuttlefish-boot-gate's abi/abilist/uname
* triad so a wrong-arch image fails closed instead of passing the
* brand-level checks on the wrong CPU. Omitted by default, so the
* x86_64/arm64 callers are unaffected. This is the canonical home of the
* arch-triad assertion (chip/sw/aosp-device/cuttlefish-boot-gate.sh).
*/
function validateExpectedAbi(adb, serial, expectedAbi) {
const abi = shell(adb, serial, "getprop ro.product.cpu.abi").trim();
if (abi !== expectedAbi) {
throw new Error(
`ro.product.cpu.abi must be ${expectedAbi}; found ${abi || "<empty>"}`,
);
}
const abilist = shell(adb, serial, "getprop ro.product.cpu.abilist").trim();
if (!abilist.split(",").includes(expectedAbi)) {
throw new Error(
`ro.product.cpu.abilist must contain ${expectedAbi}; found ${abilist || "<empty>"}`,
);
}
const unameM = shell(adb, serial, "uname -m").trim();
if (unameM !== expectedAbi) {
throw new Error(
`uname -m must be ${expectedAbi}; found ${unameM || "<empty>"}`,
);
}
return { abi, abilist, unameM };
}
function validateProductProperty(adb, serial, brand) {
const prop = `ro.${brand.propertyPrefix}.product`;
const product = shell(adb, serial, `getprop ${prop}`);
if (product !== brand.productName) {
throw new Error(
`${prop} must be ${brand.productName}; found ${product || "<empty>"}`,
);
}
return product;
}
function validateBootProperties(adb, serial, brand) {
const expectedProps = brandBootProperties(brand);
const properties = {};
for (const [name, expected] of Object.entries(expectedProps)) {
const actual = shell(adb, serial, `getprop ${name}`).trim();
if (actual !== expected) {
throw new Error(
`${name} must be ${expected}; found ${actual || "<empty>"}`,
);
}
properties[name] = actual;
}
return properties;
}
function validatePackagePath(adb, serial, brand) {
const pmPath = shell(adb, serial, `pm path ${brand.packageName}`);
assertIncludes(
pmPath,
`/system/priv-app/${brand.appName}/`,
`${brand.appName} package path`,
);
return pmPath;
}
function validateHomeResolution(adb, serial, brand) {
const resolved = shell(
adb,
serial,
"cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME",
);
assertIncludes(resolved, brand.packageName, "HOME activity resolution");
return resolved;
}
/**
* For every system intent whose default app we stripped from
* PRODUCT_PACKAGES, prove a brand activity is the resolver. Without
* these assertions a stripped phone could pass HOME/Dialer/SMS role
* validation while silently failing to open URLs / set alarms / take
* photos — exactly the regression class this list catches.
*/
const REPLACEMENT_INTENT_RESOLUTIONS = [
{
label: "VIEW http",
args: '-a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "http://example.com"',
},
{
label: "VIEW https",
args: '-a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://example.com"',
},
{
label: "STILL_IMAGE_CAMERA",
args: "-a android.media.action.STILL_IMAGE_CAMERA",
},
{
label: "IMAGE_CAPTURE",
args: "-a android.media.action.IMAGE_CAPTURE",
},
{
label: "SET_ALARM",
args: "-a android.intent.action.SET_ALARM",
},
{
label: "SHOW_ALARMS",
args: "-a android.intent.action.SHOW_ALARMS",
},
{
label: "APP_CONTACTS launcher",
args: "-a android.intent.action.MAIN -c android.intent.category.APP_CONTACTS",
},
{
label: "APP_CALENDAR launcher",
args: "-a android.intent.action.MAIN -c android.intent.category.APP_CALENDAR",
},
];
function validateReplacementIntents(adb, serial, brand) {
const resolutions = {};
for (const { label, args } of REPLACEMENT_INTENT_RESOLUTIONS) {
const resolved = shell(
adb,
serial,
`cmd package resolve-activity --brief ${args}`,
);
if (!resolved.includes(brand.packageName)) {
throw new Error(
`Intent "${label}" did not resolve to ${brand.packageName}; got:\n${resolved}`,
);
}
resolutions[label] = resolved;
}
return resolutions;
}
function validateRoles(adb, serial, brand) {
const roles = {};
for (const role of REQUIRED_ROLES) {
const holders = shell(adb, serial, `cmd role get-role-holders ${role}`);
assertIncludes(holders, brand.packageName, `${role} holder list`);
roles[role] = holders;
}
return roles;
}
function validatePackageFlagsAndPermissions(adb, serial, brand) {
const dump = shell(adb, serial, `dumpsys package ${brand.packageName}`);
assertMatches(
dump,
/pkgFlags=\[[^\]]*\bSYSTEM\b/i,
`${brand.appName} package flags`,
);
assertMatches(
dump,
/privateFlags=\[[^\]]*\bPRIVILEGED\b/i,
`${brand.appName} private flags`,
);
for (const permission of REQUIRED_GRANTED_PERMISSIONS) {
assertMatches(
dump,
new RegExp(`${escapeRegExp(permission)}[^\\n]*granted=true`, "i"),
`${permission} grant`,
);
}
return dump;
}
function validateAppOps(adb, serial, brand) {
const usageStats = shell(
adb,
serial,
`cmd appops get ${brand.packageName} GET_USAGE_STATS`,
);
assertMatches(usageStats, /\ballow\b/i, "GET_USAGE_STATS appop");
return { GET_USAGE_STATS: usageStats };
}
function validateForbiddenPackages(adb, serial) {
// pm list packages prints one `package:<name>` per line. Use a Set of
// exact lines instead of substring matching: without this, looking for
// `com.android.contacts` matches the unrelated `com.android.contactspicker`
// (the system contact-picker UI), and `com.android.music` matches
// `com.android.musicfx` (the equalizer service).
const installed = new Set(
shell(adb, serial, "pm list packages")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith("package:"))
.map((line) => line.slice("package:".length)),
);
const installedForbidden = FORBIDDEN_STOCK_PACKAGES.filter((pkg) =>
installed.has(pkg),
);
if (installedForbidden.length > 0) {
throw new Error(
`Forbidden stock packages are installed: ${installedForbidden.join(", ")}`,
);
}
return installedForbidden;
}
function validateLogcat(adb, serial, brand) {
const logcat = runAdb(adb, serial, ["logcat", "-d", "-v", "brief"]);
const patterns = logcatFailurePatterns(brand);
const failures = patterns.flatMap((pattern) =>
logcat
.split(/\r?\n/)
.filter((line) => pattern.test(line))
.slice(0, 20),
);
if (failures.length > 0) {
throw new Error(
`Boot log contains failure markers:\n${failures.join("\n")}`,
);
}
return "clean";
}
export async function validateBootedDevice(options, brand) {
const adb = resolveAdb(options.adb);
const serial = options.serial || null;
await waitForBoot({ adb, serial, timeoutMs: options.timeoutMs });
const result = {
adb,
serial,
product: validateProductProperty(adb, serial, brand),
bootProperties: validateBootProperties(adb, serial, brand),
packagePath: validatePackagePath(adb, serial, brand),
homeResolution: validateHomeResolution(adb, serial, brand),
replacementIntents: validateReplacementIntents(adb, serial, brand),
roles: validateRoles(adb, serial, brand),
appOps: validateAppOps(adb, serial, brand),
forbiddenPackages: validateForbiddenPackages(adb, serial),
logcat: options.skipLogcat ? "skipped" : validateLogcat(adb, serial, brand),
...(options.expectedAbi
? { abi: validateExpectedAbi(adb, serial, options.expectedAbi) }
: {}),
};
validatePackageFlagsAndPermissions(adb, serial, brand);
return result;
}
export async function main(argv = process.argv.slice(2)) {
const { brand, remaining } = loadBrandFromArgv(argv);
const args = parseSubArgs(remaining);
const result = await validateBootedDevice(args, brand);
if (args.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(
`[distro-android:boot-validate] Booted ${brand.distroName} device checks passed.`,
);
}
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
await main();
}