mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
565 lines
20 KiB
JavaScript
565 lines
20 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) => tapHostedAndroidPoint(emulator, point),
|
|
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' ? 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
|
|
})
|