Files
orca/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs
T
Jinwoo-H 79f54a8b49 fix(mobile): wait for adversarial review content and route readiness
Wait for the diff body rather than just Review controls, and handle Back through Source Control before opening file previews. iOS confirms terminal links and rendered adversarial diff; the full unattended journey remains tracked. Required gates pass in native-chat-read-gates.
2026-09-06 19:01:27 -04:00

574 lines
21 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 { verifyHostedAndroidAdversarialTerminalLinks } from './hosted-android-adversarial-terminal-links.mjs'
import { stageHostedAdversarialTerminalLinks } from './hosted-adversarial-terminal-links.mjs'
import { verifyHostedAndroidAgentHistoryJourney } from './hosted-android-agent-history-journey.mjs'
import { HOSTED_ADVERSARIAL_CONTENT_MARKER } from './hosted-adversarial-repository-fixture.mjs'
import {
createHostedAdversarialRuntimeFixture,
removeHostedAdversarialRuntimeFixture
} from './hosted-adversarial-runtime-fixture.mjs'
import {
verifyHostedAdversarialProviderReview,
verifyHostedAdversarialTasks
} from './hosted-adversarial-provider-content.mjs'
import {
readHostedAndroidExitInfo,
verifyHostedAndroidPrivacyAudit
} from './hosted-android-privacy-audit.mjs'
import {
tapHostedAndroidAccessibilityControl,
tapHostedAndroidPoint
} from './hosted-android-emulator-accessibility.mjs'
import {
activateHostedAndroidWorkspaceControl,
prepareHostedAndroidWorkspaceInput
} from './hosted-android-workspace-activation.mjs'
import {
assertHostedAndroidBridgeLogClean,
buildHostedAndroidDebugApp,
forwardHostedAndroidInspector,
installAndResetHostedAndroidApp,
launchHostedAndroidDevClient,
openHostedAndroidUrl,
resolveHostedAndroidAdb,
startHostedAndroidMetro,
stopHostedAndroidApp,
waitForHostedAndroidReactReady
} from './hosted-android-emulator-session.mjs'
import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs'
import { pairHostedAndroidApp } from './hosted-android-pairing.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 { inspectHostedWebViewAdversarialFiles } from './hosted-webview-adversarial-files.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 }))
}
probe = await stage('network isolation sentinel', startHostedWebViewSecurityProbe)
if (options.adversarialContent) {
adversarialFixture = await stage('adversarial repository fixture', () =>
createHostedAdversarialRuntimeFixture({ probePort: probe.port })
)
}
runtime = await stage('temporary paired desktop runtime', () =>
startHeadlessPairingRuntime({
enabled: true,
orcaCli,
cwd: worktree,
environment: adversarialFixture?.environment,
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
if (adversarialFixture) {
await stage('provider error workspace registration', () =>
registerWorktreeForPairingRuntime(runtime, worktree, {
orca: runOrca,
logStep: () => {},
logSuccess: () => {}
})
)
}
await stage('test workspace registration', () =>
registerWorktreeForPairingRuntime(runtime, testWorkspace, {
orca: runOrca,
logStep: () => {},
logSuccess: () => {}
})
)
const adversarialTerminalHandle = adversarialFixture
? await stage('adversarial terminal fixture', () =>
stageHostedAdversarialTerminalLinks({
orcaCli,
pairingRuntimeUserDataPath: runtime.userData,
positiveFilePath: adversarialFixture.repositoryFiles[0].filename,
probePort: probe.port,
probeToken: probe.token,
timeoutMs: options.timeoutMs,
worktree: testWorkspace
})
)
: null
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)
)
await stage('React runtime', () => waitForHostedAndroidReactReady(adb, options.timeoutMs))
const emulator = { adb }
await stage('native pairing', () =>
pairHostedAndroidApp({ adb, pairingUrl: runtime.pairingUrl, timeoutMs: 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: 'Host 1',
timeoutMs: options.timeoutMs
})
)
const expectedWorkspace = path.basename(testWorkspace)
const workspaceRowName = adversarialFixture?.workspaceRowName ?? expectedWorkspace
let 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 })
)
let adversarialProviderContent = null
if (adversarialFixture) {
const result = await stage('adversarial task and error presentation', () =>
verifyHostedAdversarialTasks({
activatePoint: (point, context) =>
tapHostedAndroidJourneyControl(
emulator,
point,
context.label,
context.attempt,
context.document
),
discoveryUrl,
document: workspaceDocument,
timeoutMs: options.timeoutMs
})
)
adversarialProviderContent = { tasks: result.evidence, review: null }
workspaceDocument = result.workspaceDocument
}
const sessionDocument = await stage(
'workspace activation and hosted Session route',
async () => {
await prepareHostedAndroidWorkspaceInput(emulator)
let activeDocument = workspaceDocument
let lastError
for (let attempt = 0; attempt < 3; attempt += 1) {
if (!activeDocument.href.includes('/session/')) {
await activateHostedWorkspaceRow(
activeDocument,
workspaceRowName,
(document, target) =>
activateHostedAndroidWorkspaceControl(emulator, document, target),
Math.min(options.timeoutMs, 15_000),
() =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: workspaceRowName,
timeoutMs: options.timeoutMs
})
)
}
try {
return await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: '1 tab',
expectedHrefIncludes: '/session/',
requireInteractiveControls: false,
timeoutMs: Math.min(options.timeoutMs, 15_000)
})
} catch (error) {
lastError = error
activeDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Host 1',
timeoutMs: options.timeoutMs
})
}
}
const state = await readHostedWebViewState(activeDocument)
throw new Error(
`${lastError instanceof Error ? lastError.message : String(lastError)}. Diagnostics labels=${JSON.stringify(
state.labels
)} bodyText=${JSON.stringify(state.bodyText)}`
)
}
)
let agentHistory = null
let adversarialContent = null
let adversarialTerminalLinks = null
let adversarialReviewDocument = null
const adversarialObservations = []
let sourceControlReview = null
let isolationDocument = sessionDocument
if (!options.securityOnly) {
let sourceSessionDocument = sessionDocument
if (options.adversarialContent) {
const result = await stage('adversarial terminal links', () =>
verifyHostedAndroidAdversarialTerminalLinks({
discoveryUrl,
document: sourceSessionDocument,
emulator,
orcaCli,
pairingRuntimeUserDataPath: runtime.userData,
positiveFilePath: adversarialFixture.repositoryFiles[0].filename,
probe,
tapPoint: tapHostedAndroidPoint,
terminalHandle: adversarialTerminalHandle,
timeoutMs: options.timeoutMs,
worktree: testWorkspace
})
)
adversarialTerminalLinks = result.evidence
sourceSessionDocument = result.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: '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' || phase === 'review'
? HOSTED_ADVERSARIAL_CONTENT_MARKER
: undefined,
timeoutMs: Math.min(options.timeoutMs, 15_000)
})
)
if (phase === 'review') {
adversarialReviewDocument = document
}
}
: undefined,
inspectProviderContent: adversarialProviderContent
? async ({ document }) => {
adversarialProviderContent.review = await verifyHostedAdversarialProviderReview({
document,
timeoutMs: options.timeoutMs
})
}
: undefined,
timeoutMs: options.timeoutMs,
tapPoint: tapHostedAndroidJourneyControl
})
)
if (options.adversarialContent) {
adversarialContent = await stage('adversarial filename and diff presentation', async () => {
try {
return await inspectAndroidAdversarialContent({
document: adversarialReviewDocument,
fixture: adversarialFixture,
observations: adversarialObservations,
timeoutMs: options.timeoutMs
})
} 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,
adversarialProviderContent,
adversarialTerminalLinks,
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 removeHostedAdversarialRuntimeFixture(adversarialFixture)
}
}
}
async function inspectAndroidAdversarialContent({ document, fixture, observations, timeoutMs }) {
if (!document) {
throw new Error('Hosted Android adversarial Review document is unavailable')
}
return {
...hostedWebViewAdversarialContentObservations(observations),
...(await inspectHostedWebViewAdversarialFiles({
document,
fixture,
timeoutMs
}))
}
}
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, undefined, {
horizontalPosition: 0.15
})
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
})