Files
orca/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs
T

515 lines
18 KiB
JavaScript

#!/usr/bin/env node
import { execFile } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
import { resolveEmulatorOrcaCli } from './emulator-orca-cli-selection.mjs'
import { verifyHostedAndroidAgentHistoryJourney } from './hosted-android-agent-history-journey.mjs'
import {
createHostedAdversarialRepositoryFixture,
HOSTED_ADVERSARIAL_CONTENT_MARKER,
removeHostedAdversarialRepositoryFixture
} from './hosted-adversarial-repository-fixture.mjs'
import {
readHostedAndroidExitInfo,
verifyHostedAndroidPrivacyAudit
} from './hosted-android-privacy-audit.mjs'
import {
tapHostedAndroidAccessibilityControl,
tapHostedAndroidPoint,
waitForHostedAndroidAccessibilityControlMatch
} from './hosted-android-emulator-accessibility.mjs'
import {
assertHostedAndroidBridgeLogClean,
buildHostedAndroidDebugApp,
forwardHostedAndroidInspector,
installAndResetHostedAndroidApp,
launchHostedAndroidDevClient,
openHostedAndroidUrl,
resolveHostedAndroidAdb,
startHostedAndroidMetro,
stopHostedAndroidApp
} from './hosted-android-emulator-session.mjs'
import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs'
import { HOSTED_MOBILE_APP_ROUTE_URL } from './hosted-mobile-e2e-launch.mjs'
import {
activateHostedWebViewControl,
readHostedWebViewState,
readHostedWebViewTextPoint,
verifyHostedWebViewNavigationIsolation,
verifyHostedWebViewNetworkIsolation,
waitForVisibleHostedWebView
} from './hosted-webview-cdp-session.mjs'
import { verifyHostedWebViewExecutableIsolation } from './hosted-webview-executable-isolation.mjs'
import { verifyHostedWebViewPrivacyIsolation } from './hosted-webview-privacy-isolation.mjs'
import {
captureHostedWebViewAdversarialObservation,
hostedWebViewAdversarialContentObservations
} from './hosted-webview-adversarial-content.mjs'
import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs'
import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs'
import { verifyHostedSourceControlReviewJourney } from './hosted-ios-source-control-review-journey.mjs'
import { startHostedWebViewSecurityProbe } from './hosted-ios-webview-security-probe.mjs'
import {
registerWorktreeForPairingRuntime,
startHeadlessPairingRuntime
} from './start-emulator-pairing-runtime.mjs'
const execFileAsync = promisify(execFile)
const worktree = path.resolve(import.meta.dirname, '../..')
const mobileDir = path.join(worktree, 'mobile')
const androidDir = path.join(mobileDir, 'android')
const defaultApk = path.join(androidDir, 'app/build/outputs/apk/debug/app-debug.apk')
const options = parseOptions(process.argv.slice(2))
const adb = resolveHostedAndroidAdb(options.adb)
const runtimeDirectory = resolveHostedWebViewRuntimeDirectory({
worktree,
override: process.env.ORCA_E2E_MOBILE_WEBVIEW_RUN_DIRECTORY
})
const orcaCli = resolveEmulatorOrcaCli({
explicitCommand: process.env.ORCA_CLI,
managedCommand: process.env.ORCA_CLI_COMMAND,
devRepoRoot: process.env.ORCA_DEV_REPO_ROOT,
worktree,
cwd: worktree
}).command
async function main() {
let runtime
let metro
let probe
let inspector
let exitInfoBaseline
let adversarialFixture
const reversePorts = new Set()
try {
await stage('Android emulator', () => runAndroidAdb(adb, ['get-state']))
await stage('Android log reset', () => runAndroidAdb(adb, ['logcat', '-c']))
exitInfoBaseline = await stage('Android exit-info baseline', () =>
readHostedAndroidExitInfo(adb)
)
if (!options.skipNativeBuild) {
await stage('Android debug app build', () => buildHostedAndroidDebugApp({ adb, androidDir }))
}
if (options.adversarialContent) {
adversarialFixture = await stage(
'adversarial repository fixture',
createHostedAdversarialRepositoryFixture
)
}
probe = await stage('network isolation sentinel', startHostedWebViewSecurityProbe)
runtime = await stage('temporary paired desktop runtime', () =>
startHeadlessPairingRuntime({
enabled: true,
orcaCli,
cwd: worktree,
runDirectory: path.join(runtimeDirectory, 'paired-host'),
lanIpCandidates: () => ['127.0.0.1'],
logStep: () => {},
logSuccess: () => {}
})
)
if (!options.adversarialContent) {
runtime.env.ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE = '1'
}
const testWorkspace = adversarialFixture?.root ?? worktree
await stage('test workspace registration', () =>
registerWorktreeForPairingRuntime(runtime, testWorkspace, {
orca: runOrca,
logStep: () => {},
logSuccess: () => {}
})
)
metro = await stage('Metro', () =>
startHostedAndroidMetro({ mobileDir, pairingUrl: runtime.pairingUrl })
)
for (const port of [runtime.port, metro.port, probe.port]) {
await runAndroidAdb(adb, ['reverse', `tcp:${port}`, `tcp:${port}`])
reversePorts.add(port)
}
await stage('sentinel reachability red check', () => proveSentinelReachability(adb, probe))
await stage('exact Android app install', () =>
installAndResetHostedAndroidApp(adb, options.apk)
)
await stage('development client launch', () =>
launchHostedAndroidDevClient(adb, metro.port, probe)
)
const emulator = { adb }
await stage('native pairing', () =>
pairAndroidApp(emulator, runtime.pairingUrl, options.timeoutMs)
)
await stage('native hybrid route handoff', async () => {
await openHostedAndroidUrl(adb, HOSTED_MOBILE_APP_ROUTE_URL)
})
inspector = await stage('Android WebView inspector', () =>
forwardHostedAndroidInspector(adb, options.timeoutMs)
)
const discoveryUrl = `http://127.0.0.1:${inspector.port}`
await stage('hosted workspace route', () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
timeoutMs: options.timeoutMs
})
)
const expectedWorkspace = path.basename(testWorkspace)
const workspaceRowName = adversarialFixture?.workspaceRowName ?? expectedWorkspace
const workspaceDocument = await stage('hosted workspace data', () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: expectedWorkspace.toLocaleUpperCase(),
timeoutMs: options.timeoutMs
})
)
const privacyIsolation = await stage('workspace privacy isolation probe', () =>
verifyHostedWebViewPrivacyIsolation({ document: workspaceDocument })
)
await stage('workspace activation', async () => {
try {
await activateHostedWorkspaceRow(
workspaceDocument,
workspaceRowName,
(document, target) => activateAndroidWorkspaceControl(emulator, document, target),
Math.min(options.timeoutMs, 15_000),
() =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: workspaceRowName,
timeoutMs: options.timeoutMs
})
)
} catch (error) {
let diagnostics = 'unavailable'
try {
const currentDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
timeoutMs: options.timeoutMs
})
const state = await readHostedWebViewState(currentDocument)
diagnostics = `labels=${JSON.stringify(state.labels)} bodyText=${JSON.stringify(
state.bodyText
)}`
} catch (diagnosticError) {
diagnostics = `unavailable: ${
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError)
}`
}
throw new Error(
`${error instanceof Error ? error.message : String(error)}. Diagnostics ${diagnostics}`
)
}
})
const sessionDocument = await stage('hosted Session route', () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: '1 tab',
expectedHrefIncludes: '/session/',
requireInteractiveControls: false,
timeoutMs: options.timeoutMs
})
)
let agentHistory = null
let adversarialContent = null
const adversarialObservations = []
let sourceControlReview = null
let isolationDocument = sessionDocument
if (!options.securityOnly) {
let sourceSessionDocument = sessionDocument
if (!options.adversarialContent) {
const agentHistoryResult = await stage('Agent History journey', () =>
verifyHostedAndroidAgentHistoryJourney({
discoveryUrl,
emulator,
sessionDocument,
timeoutMs: options.timeoutMs
})
)
const { returnedSessionDocument, ...evidence } = agentHistoryResult
agentHistory = evidence
sourceSessionDocument = returnedSessionDocument
}
sourceControlReview = await stage('Source Control and Review journey', () =>
verifyHostedSourceControlReviewJourney({
discoveryUrl,
emulator,
sessionDocument: sourceSessionDocument,
expectedSessionDiffText: options.adversarialContent ? '2 tabs' : '3 tabs',
inspectChangedContent: options.adversarialContent
? async ({ document, phase }) => {
if (phase === 'sessionDiff') {
await activateAndroidAdversarialDiffTab(
emulator,
document,
adversarialFixture.filename
)
}
adversarialObservations.push(
await captureHostedWebViewAdversarialObservation({
document,
expectedMarker:
phase === 'sessionDiff' ? HOSTED_ADVERSARIAL_CONTENT_MARKER : undefined,
timeoutMs: Math.min(options.timeoutMs, 15_000)
})
)
}
: undefined,
timeoutMs: options.timeoutMs,
tapPoint: tapHostedAndroidJourneyControl
})
)
if (options.adversarialContent) {
adversarialContent = await stage('adversarial filename and diff presentation', () => {
try {
return hostedWebViewAdversarialContentObservations(adversarialObservations)
} catch (error) {
const states = adversarialObservations.map(({ state }) => ({
bodyText: state.bodyText.slice(0, 1024),
labels: state.labels.slice(0, 16)
}))
throw new Error(
`${error instanceof Error ? error.message : String(error)}. States ${JSON.stringify(states)}`
)
}
})
}
isolationDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'reviewed',
expectedHrefIncludes: '/review/',
timeoutMs: options.timeoutMs
})
}
const networkIsolation = await stage('network isolation probe', () =>
verifyHostedWebViewNetworkIsolation({
document: isolationDocument,
probeId: probe.token
})
)
const navigationIsolation = await stage('navigation isolation probe', () =>
verifyHostedWebViewNavigationIsolation({
document: isolationDocument,
discoveryUrl,
probeId: probe.token
})
)
const executableIsolation = await stage('executable isolation probe', () =>
verifyHostedWebViewExecutableIsolation({
document: isolationDocument,
probeId: probe.token
})
)
await delay(500)
if (probe.observations.length > 0) {
throw new Error(
`Hosted Android WebView reached the sentinel: ${probe.observations.join(', ')}`
)
}
await stage('Android bridge log audit', () => assertHostedAndroidBridgeLogClean(adb))
const privacyAudit = await stage('Android privacy and exit-info audit', () =>
verifyHostedAndroidPrivacyAudit({
adb,
baselineExitInfo: exitInfoBaseline,
devServerPort: metro.port,
probePort: probe.port
})
)
console.log(
JSON.stringify(
{
ok: true,
device: await runAndroidAdb(adb, ['shell', 'getprop', 'ro.product.model']),
pid: inspector.pid,
workspace: expectedWorkspace,
agentHistory,
adversarialContent,
sourceControlReview,
networkIsolation,
navigationIsolation,
executableIsolation,
privacyIsolation,
privacyAudit,
sentinelObservations: probe.observations
},
null,
2
)
)
} finally {
await stopHostedAndroidApp(adb)
if (inspector) {
await runAndroidAdb(adb, ['forward', '--remove', `tcp:${inspector.port}`]).catch(() => {})
}
for (const port of reversePorts) {
await runAndroidAdb(adb, ['reverse', '--remove', `tcp:${port}`]).catch(() => {})
}
await metro?.stop()
await runtime?.stop({ shutdownDaemon: true })
await probe?.stop()
if (adversarialFixture) {
await removeHostedAdversarialRepositoryFixture(adversarialFixture)
}
}
}
async function pairAndroidApp(emulator, pairingUrl, timeoutMs) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
await openHostedAndroidUrl(adb, pairingUrl)
try {
const control = await waitForHostedAndroidAccessibilityControlMatch(
emulator,
['Close', 'Continue', 'Pair'],
Math.min(5_000, deadline - Date.now())
)
if (control.label === 'Pair') {
await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000)
break
}
await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000)
} catch {
// The first deep link can arrive before the development bundle mounts.
}
}
const destination = await waitForHostedAndroidAccessibilityControlMatch(
emulator,
[
'Open sessions in Chat UI',
'Open sessions in the terminal',
'Enable agent notifications',
'Skip notifications for now',
'Show paired hosts',
'Back to home'
],
Math.max(1_000, deadline - Date.now())
)
if (destination.label === 'Back to home') {
throw new Error('Android pairing failed before reaching the onboarding flow')
}
}
async function activateAndroidWorkspaceControl(emulator, document, target) {
if (target.kind !== 'text') {
return activateHostedWebViewControl(document, target)
}
if (target.reveal) {
await readHostedWebViewTextPoint(document, target.value, undefined, {
ignoreCase: target.ignoreCase,
occurrence: target.occurrence,
reveal: true
})
await delay(250)
}
const point = await readHostedWebViewTextPoint(document, target.value, undefined, {
ignoreCase: target.ignoreCase,
occurrence: target.occurrence
})
await tapHostedAndroidPoint(emulator, point)
}
async function tapHostedAndroidJourneyControl(emulator, point, label, attempt = 0, document) {
if (label && attempt === 0) {
try {
return await tapHostedAndroidAccessibilityControl(emulator, label, 5_000)
} catch {
// Chromium may omit a WebView descendant during an accessibility-tree refresh.
}
}
if (label && attempt > 0 && document) {
return activateHostedWebViewControl(document, { kind: 'label', value: label, reveal: true })
}
return tapHostedAndroidPoint(emulator, point)
}
async function activateAndroidAdversarialDiffTab(emulator, document, filename) {
const point = await readHostedWebViewTextPoint(document, filename)
await tapHostedAndroidPoint(emulator, point)
await delay(250)
}
async function proveSentinelReachability(command, probe) {
await runAndroidAdb(command, ['shell', 'nc', '-z', '-w', '5', '127.0.0.1', String(probe.port)])
if (!probe.observations.includes('tcp:connection')) {
throw new Error('Android loopback sentinel red check did not arrive')
}
probe.reset()
}
async function runOrca(args, runOptions) {
const result = await execFileAsync(orcaCli, args, {
cwd: runOptions.cwd,
env: runOptions.env,
encoding: 'utf8',
timeout: runOptions.timeout
})
return { stdout: result.stdout.trim(), stderr: result.stderr.trim() }
}
async function stage(label, run) {
process.stderr.write(`[android-e2e] ${label}...\n`)
try {
const result = await run()
process.stderr.write(`[android-e2e] ${label}: ok\n`)
return result
} catch (error) {
throw new Error(`${label} failed: ${error instanceof Error ? error.message : String(error)}`, {
cause: error
})
}
}
function parseOptions(args) {
const result = {
adb: null,
apk: defaultApk,
adversarialContent: false,
securityOnly: false,
skipNativeBuild: false,
timeoutMs: 90_000
}
for (let index = 0; index < args.length; index += 1) {
const option = args[index]
if (option === '--') {
continue
} else if (option === '--adb') {
result.adb = requireValue(args, ++index, option)
} else if (option === '--apk') {
result.apk = path.resolve(requireValue(args, ++index, option))
} else if (option === '--adversarial-content') {
result.adversarialContent = true
} else if (option === '--skip-native-build') {
result.skipNativeBuild = true
} else if (option === '--security-only') {
result.securityOnly = true
} else if (option === '--timeout-ms') {
result.timeoutMs = Number.parseInt(requireValue(args, ++index, option), 10)
} else {
throw new Error(`Unknown option: ${option}`)
}
}
if (!Number.isInteger(result.timeoutMs) || result.timeoutMs < 1_000) {
throw new Error('--timeout-ms must be an integer of at least 1000')
}
if (result.adversarialContent && result.securityOnly) {
throw new Error('--adversarial-content and --security-only are mutually exclusive')
}
return result
}
function requireValue(args, index, option) {
if (!args[index]) {
throw new Error(`${option} requires a value`)
}
return args[index]
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
main().catch((error) => {
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error))
process.exitCode = 1
})