diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md
index 3dc964a6582..b5d0fa59c19 100644
--- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md
+++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md
@@ -1279,8 +1279,13 @@ copy.
operations resolve the current execution owner inside Desktop; real SSH
and Relay compositions pass, while the broader release topology matrix
remains a validation gate.
-- [ ] Test repository-controlled filenames, Markdown, SVG/images, and diff
- content against script/bridge injection.
+- [~] Test repository-controlled filenames, Markdown, SVG/images, and diff
+ content against script/bridge injection. A disposable repository now
+ drives a hostile `
` filename and changed diff line through
+ Source Control, Session diff, and Review in the exact iOS Simulator and
+ Android Debug emulator apps. Both render the payloads literally without
+ creating an image or setting either execution sentinel. Markdown,
+ SVG/image, physical-device, and release-runtime coverage remains.
- [ ] Test large file counts, large diffs, binary files, conflicts, detached
state, missing upstream, and provider failures.
- [ ] Complete physical-device diff/file performance validation.
@@ -1575,9 +1580,15 @@ copy.
unchanged raw-source fallback. Focused document and package-budget boundaries
pass. A deterministic corpus now proves bounded filenames, diff lines,
task/provider fields, and errors remain inert React Native text, and existing
- terminal-link tests reject unsupported targets. The exact Orca app route,
- native nested component, Android, physical-device, Release, broader live
- adversarial interaction, and independent review remain open.
+ terminal-link tests reject unsupported targets. A disposable hostile
+ repository now passes its `
` filename and changed diff line
+ through the exact Source Control, Session diff, and Review routes on iPhone
+ 17 Pro / iOS 26.5 Simulator and Pixel 9 Pro API 36 arm64 Debug. Both render
+ the payloads literally, create no matching image, leave both execution
+ sentinels unset, and retain network/navigation/executable and privacy
+ isolation. Exact-app HTML, SVG/image, Markdown, Mermaid, terminal-link,
+ task/provider, bounded-error, physical-device, Release, broader live
+ interaction, and independent review remain open.
- [~] Fuzz manifest, chunks, asset paths, MIME types, CSP, and cache metadata.
A mirrored TypeScript, Swift, and Kotlin scalar-type corpus rejects quoted and
Boolean schema, bridge, total-byte, and asset-byte integers before staging. It
@@ -1959,11 +1970,8 @@ gates, max-lines, localization, formatting, diff hygiene, and production
package verification pass. The current privacy-hardening package is
`3c0f364f9cb6f1785d1d08fdeb81ca5367b091706c24d124374a88578839e745`:
50 assets, 9,135,273 raw bytes, and 2,644,558 gzip bytes. The full mobile suite
-passes 588 files / 3,499 tests with 2 expected skips. The latest full root run
-from before the final upstream rebases passes 3,839 files / 40,326 tests with 71
-expected skips except for one unrelated load-sensitive remote-runtime socket
-setup failure; its complete one-test file passes in a 194-millisecond isolated
-rerun.
+passes 589 files / 3,511 tests with 2 expected skips. The latest full root run
+passes 3,854 files / 40,508 tests with 71 expected skips.
| Date | Workstream | Evidence | Result |
| ---------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -2359,6 +2367,10 @@ rerun.
| 2026-07-27 | RNW package | Exact-source production build and independent verifier after the Source Control/Review repair and lint-safe bridge extraction | Passed; build `4b7df7d47a9b949b788c9f88bac5f68e8b18eb1ea7c615e16ebc4be126c8d07d`, 49 assets, 9,328,523 raw bytes, and 2,697,136 gzip bytes |
| 2026-07-27 | iOS Source Control | Exact hosted app opened Source Control from Session, opened a changed file as a second Session diff tab, then exercised standalone Review | Passed on iPhone 17 Pro Simulator; existing segments and review controls retained; network and navigation isolation probes passed |
| 2026-07-27 | Final validation pass | Full mobile/root suites; mobile, RNW, Node, CLI, and renderer typechecks; full mobile/RNW lint; root reliability/max-lines/skill/localization-catalog phases; focused bridge/provider coverage | Passed; mobile 544 files / 3,248 tests with 2 skips; root 3,551 files / 37,420 tests with 60 skips; 56 focused tests; root localization coverage retains the recorded unrelated `Ghostty` finding |
+| 2026-07-29 | Adversarial content | Disposable hostile filename and diff through exact Source Control, active Session diff, and standalone Review on iOS 26.5 Simulator and Android API 36 emulator | Passed; both payloads render literally, zero injected images, both execution sentinels unset, and network/navigation/executable/privacy audits green |
+| 2026-07-29 | Focused validation | Adversarial fixture/inspection, route hooks, workspace activation, CDP reveal, Android privacy/log filtering, and focused-option coverage | Passed; 7 files / 78 tests |
+| 2026-07-29 | Full validation | Mobile/root suites, all project typechecks, root/mobile/mobile-web lint and code-quality audits, reliability, localization, max-lines, formatting, package verification, and diff hygiene | Passed; mobile 589 files / 3,511 tests with 2 skips; root 3,854 files / 40,508 tests with 71 skips; build `3c0f364f…` remains 50 assets / 9,135,273 raw / 2,644,558 gzip bytes |
+| 2026-07-29 | Rebase | Rebased all 58 migration commits onto `origin/main` at `4543bb6826`; branch divergence `0/58`; rebuilt and independently verified the hosted package | Passed without conflicts; exact build remains `3c0f364f…`, 50 assets, 9,135,273 raw bytes, and 2,644,558 gzip bytes |
## Status and Decision Log
@@ -2883,3 +2895,8 @@ rerun.
| 2026-07-29 | Complete | The exact Pixel 9 Pro API 36 arm64 Debug app passes private-origin DOM/History, page-storage/cookie, network/navigation/executable isolation, fresh `logcat`, and `ApplicationExitInfo` review. The 11,459-byte document and 975,464-byte log slice contain zero privileged marker or unexpected WebSocket URL; the sentinel records zero escaped request and Android records no new crash/native-crash/ANR/initialization/resource failure. Two focused files / 28 tests cover shared iOS/Android markers, Android exit/debug-URL mutations, and live-gate integration. |
| 2026-07-29 | Complete | Post-audit validation passes 588 mobile files / 3,499 tests with 2 expected skips; all project typechecks; root/mobile/mobile-web lint and code-quality audits; 56 reliability gates; localization; max-lines; full-mobile formatting; changed-code quality; React Doctor with zero new migration findings; exact `3c0f364f…` package verification; and diff hygiene. |
| 2026-07-29 | Complete | Rebased all 56 migration commits without conflict onto `origin/main` at `fa449bc0e`; documentation reconciliation leaves the branch 57 commits ahead and zero behind. The exact `3c0f364f…` package rebuild remains 50 assets / 9,135,273 raw / 2,644,558 gzip bytes. Post-rebase validation passes 588 mobile files / 3,499 tests with 2 expected skips, all project typechecks, root/mobile/mobile-web lint and code-quality audits, 56 reliability gates, formatting, package verification, and diff hygiene. |
+| 2026-07-29 | Complete | A disposable Git fixture now places `000-
.tsx` and a hostile changed line into the unchanged hosted Source Control, Session diff, and Review presentation. The broker supplies the opaque page workspace authority; raw Desktop workspace identity never enters the hosted URL. |
+| 2026-07-29 | Complete | Exact iPhone 17 Pro / iOS 26.5 Simulator and Pixel 9 Pro API 36 arm64 Debug runs render both hostile payloads literally, create zero injected images, leave both execution markers unset, and pass network/navigation/executable isolation. iOS reports empty Local Storage, Session Storage, and cookies; Android reports zero privacy markers, sentinel observations, and new failure exit records. |
+| 2026-07-29 | Finding | The first post-rebase root run shared the machine with package, lint, and typecheck jobs and hit the known 30-second `ProjectViewWrapper` dynamic-import timeout. Its isolated file passes 2/2 in 5.5 seconds, and the subsequent standalone full root run passes completely. |
+| 2026-07-29 | Complete | Rebased all 58 migration commits without conflict onto `origin/main` at `4543bb6826`; the branch is zero behind. Post-rebase validation passes 589 mobile files / 3,511 tests with 2 expected skips and 3,854 root files / 40,508 tests with 71 expected skips. All project typechecks, root/mobile/mobile-web lint and code-quality audits, 56 reliability gates, localization, max-lines, full-mobile and plan formatting, exact `3c0f364f…` package verification, and diff hygiene pass. |
+| 2026-07-29 | Next | Finish the live HTML, SVG/image, Markdown, Mermaid, terminal-link, task/provider, and bounded-error corpus, then continue cross-host/workspace lifecycle races, sustained allocation fuzzing, independent review, and the external device/release gates. |
diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md
index 07f6b05c07a..e01d374291a 100644
--- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md
+++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md
@@ -150,7 +150,7 @@ prototype:
inventory entry, and fixtures are removed. The production `/hybrid` route,
production bridge clients, native fallback, and Experimental Settings entry
remain intentionally until the external cutover gates pass.
-- The 57-commit branch is rebased onto `origin/main` at `fa449bc0e` and remains
+- The 58-commit branch is rebased onto `origin/main` at `4543bb6826` and remains
zero behind. Upstream
native-chat launch-draft, transcript identity, loading, reconnect, and
orchestration behavior is retained in both native and hosted adapters.
@@ -207,6 +207,13 @@ prototype:
- Native package generations and hosted privileged-state namespaces use the
paired Desktop public key. Mutable profile IDs remain limited to
non-privileged navigation, diagnostics, and host selection.
+- A disposable hostile repository now exercises the real Source Control,
+ Session diff, and Review routes. The exact iPhone 17 Pro / iOS 26.5 Simulator
+ app and Pixel 9 Pro API 36 arm64 Debug app render an `
` filename
+ and changed diff line literally, create no matching image, leave both
+ execution sentinels unset, and retain network/navigation/executable and
+ privacy isolation. The hosted URL carries only the broker-issued opaque
+ workspace authority.
- Gesture-mediated native operations share one shell authority that consumes
each native-observed touch once, rejects expired or future timestamps, clears
pending authority whenever native `AppState` leaves foreground, and refuses a
@@ -1134,15 +1141,14 @@ and nested syntax text keeps the effective native font behavior. On iPhone 17
Pro Simulator, Source Control passes at 0.736% changed pixels / 0.910 mean
channel difference and Review at 2.134% / 1.947, within the 3% / 4 budgets.
-Current validation passes 570 mobile files / 3,418 tests with 2 expected skips
-and 3,818 root files / 39,968 tests with 62 expected skips. The earlier
-load-sensitive root timeouts do not recur in the latest complete run. All
-project typechecks, root/mobile/mobile-web lint and code-quality audits,
-changed-file formatting, localization, max-lines and diff hygiene, and 55
+Current validation passes 589 mobile files / 3,511 tests with 2 expected skips
+and 3,854 root files / 40,508 tests with 71 expected skips. All project
+typechecks, root/mobile/mobile-web lint and code-quality audits, changed-file
+formatting, localization, max-lines and diff hygiene, and the current 56
reliability gates pass. React Doctor reports zero blocking errors across the
migration without suppressions. The independently verified production package
-`7c7c673deb74e158cdfb99b1ca536fd88cd3ab5dac4eb8db78c43ca12f6ce31d`
-contains 50 assets and verifies at 9,290,968 raw bytes / 2,688,499 gzip bytes.
+`3c0f364f9cb6f1785d1d08fdeb81ca5367b091706c24d124374a88578839e745`
+contains 50 assets and verifies at 9,135,273 raw bytes / 2,644,558 gzip bytes.
That exact package passes the unpacked macOS arm64 → Docker SSH → actual iOS
WKWebView journey from a clean app reinstall in 1.9 minutes. Authenticated RPC
returned the packaged build with no checkout-output fallback; the unchanged
@@ -2817,6 +2823,17 @@ Both runs also pass network/navigation isolation; Android records zero sentinel
observations and no native bridge error. This does not replace physical-device,
store-signed release, fuzz, or independent adversarial evidence.
+The same exact emulator apps now pass the first live repository-content
+injection slice. A disposable Git repository presents a hostile
+`
` filename and changed line through Source Control, the active
+Session diff tab, and standalone Review. Both platforms render both payloads as
+literal text, create no matching image node, leave the filename and content
+execution sentinels unset, and pass executable, network, navigation, DOM,
+storage, cookie, log, and process-exit inspection applicable to the platform.
+This closes live filename/diff evidence only; the remaining HTML, SVG/image,
+Markdown, Mermaid, terminal-link, task/provider, bounded-error, physical-device,
+release-runtime, and independent-review corpus remains required.
+
A mirrored TypeScript, Swift, and Kotlin manifest corpus also rejects quoted and
Boolean numeric schema, bridge, total-byte, and asset-byte fields before native
staging. The corpus found Android `JSONObject.optInt` string coercion and iOS
diff --git a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md
index 18142e125e3..0c9b70be44e 100644
--- a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md
+++ b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md
@@ -56,6 +56,17 @@ search/no-match/clear flow, rejection of synthetic privileged activation, and
native-touch resume into a second Session tab before continuing through Source
Control and Review.
+A disposable repository now drives a hostile filename and changed diff line
+through the real Source Control, Session diff, and Review routes. The exact
+iPhone 17 Pro / iOS 26.5 Simulator app and Pixel 9 Pro API 36 arm64 Debug app
+both render the `
` payloads literally, create no matching image,
+leave both execution sentinels unset, and pass the existing
+network/navigation/executable and privacy audits. The fixture uses only the
+broker-issued opaque page workspace authority; raw Desktop workspace identity
+never enters the hosted URL. HTML, SVG/image, Markdown, Mermaid, terminal-link,
+task/provider, bounded-error, physical-device, release-runtime, and independent
+review coverage remain open.
+
The iPhone 17 Pro / iOS 26.5 Simulator now captures the unchanged native and
hosted Tasks and Session screens from one disposable Desktop runtime. Tasks
passes at 0.022% changed pixels, 0.084 mean channel difference, and 0.000016
@@ -102,13 +113,11 @@ pixels and 0.910 mean channel difference; Review passes at 2.134% and 1.947,
within the 3% / 4 budgets. The packaged document opts into native safe-area
insets, and nested syntax text retains the native effective font behavior.
-The migration is based on `origin/main` at `fa449bc0e`; the final rebase is
-complete and the branch is 57 commits ahead and zero behind. Post-rebase
-validation now passes 588 mobile files / 3,499 tests with 2 expected skips. The
-latest full root run from before the final upstream rebases passes
-3,839 files / 40,326 tests with 71 expected skips except for one unrelated
-load-sensitive remote-runtime socket setup failure; its complete one-test file
-passes in a 194-millisecond isolated rerun. All project typechecks,
+The migration is based on `origin/main` at `4543bb6826`; the final rebase is
+complete and the branch is 58 commits ahead and zero behind. Post-rebase
+validation plus the hostile-content slice now passes 589 mobile files / 3,511
+tests with 2 expected skips. The latest full root run passes 3,854 files /
+40,508 tests with 71 expected skips. All project typechecks,
root/mobile/mobile-web lint and code-quality audits, 56 reliability gates,
changed-file and full-mobile formatting, localization, the max-lines ratchet,
and diff hygiene pass. React Doctor reports zero new migration findings. The
@@ -315,13 +324,11 @@ requested workspace. Existing Source Control, provider-review, file, and
Markdown correlation remains in force.
The production package now verifies as `3c0f364f…`: 50 assets, 9,135,273 raw
-bytes, and 2,644,558 gzip bytes. The full mobile suite passes 588 files / 3,499
+bytes, and 2,644,558 gzip bytes. The full mobile suite passes 589 files / 3,511
tests with 2 expected skips. All project typechecks; root, mobile, and
mobile-web lint; all 56 reliability gates; localization; max-lines; formatting;
-and package verification pass. The latest full root run from before the final
-upstream rebases passes 3,839 files / 40,326 tests with 71 expected skips except
-for one unrelated load-sensitive remote-runtime socket setup failure; its
-complete one-test file passes in a 194-millisecond isolated rerun.
+and package verification pass. The latest full root run passes 3,854 files /
+40,508 tests with 71 expected skips.
The mirrored native package-store suites now pass 120 concurrent cache flows per
platform. The expanded same-host matrix covers competing distinct generations,
@@ -396,7 +403,13 @@ remain open.
- [ ] Run the deterministic filename, diff, terminal-link, provider/task,
bounded-error, HTML, SVG, Markdown, and Mermaid corpus through the exact
release app on both platforms and complete independent live interaction
- testing.
+ testing. Disposable hostile filename and diff fixtures now pass through
+ Source Control, Session diff, and Review in the exact cached iOS
+ Simulator app and Android Debug emulator app. Both platforms render the
+ payloads literally, create no injected image, leave execution sentinels
+ unset, and retain network/navigation/executable and privacy isolation.
+ The remaining sinks, physical devices, release builds, and independent
+ interaction review remain open.
- [ ] Fuzz manifests, chunks, paths, MIME types, CSP, cache metadata, bridge
envelopes, limits, ordering, cancellation, and subscriptions. The
ten-case TypeScript/Swift/Kotlin quoted/Boolean numeric manifest corpus
diff --git a/mobile/scripts/hosted-adversarial-repository-fixture.mjs b/mobile/scripts/hosted-adversarial-repository-fixture.mjs
new file mode 100644
index 00000000000..7e76e91301b
--- /dev/null
+++ b/mobile/scripts/hosted-adversarial-repository-fixture.mjs
@@ -0,0 +1,64 @@
+import { execFile } from 'node:child_process'
+import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { promisify } from 'node:util'
+
+const execFileAsync = promisify(execFile)
+const fixturePrefix = 'orca-mobile-adversarial.'
+export const HOSTED_ADVERSARIAL_FILENAME_MARKER = 'ORCA_ADVERSARIAL_FILENAME'
+export const HOSTED_ADVERSARIAL_CONTENT_MARKER = 'ORCA_ADVERSARIAL_CONTENT'
+export const HOSTED_ADVERSARIAL_WORKSPACE_ROW = 'orca-adversarial-row'
+export const HOSTED_ADVERSARIAL_FILENAME = `000-
.tsx`
+export const HOSTED_ADVERSARIAL_CONTENT = `
`
+
+export async function createHostedAdversarialRepositoryFixture() {
+ const root = await realpath(await mkdtemp(path.join(os.tmpdir(), fixturePrefix)))
+ try {
+ await git(root, ['init', '-q'])
+ await git(root, ['config', 'user.name', 'Orca Mobile Test'])
+ await git(root, ['config', 'user.email', 'mobile-test@orca.invalid'])
+ await writeFile(path.join(root, 'README.md'), 'Adversarial mobile fixture\n')
+ const blobSource = path.join(root, '.orca-adversarial-content')
+ await writeFile(blobSource, `${HOSTED_ADVERSARIAL_CONTENT}\n`)
+ const blob = await git(root, ['hash-object', '-w', blobSource])
+ await rm(blobSource)
+ await git(root, ['add', 'README.md'])
+ await git(root, [
+ 'update-index',
+ '--add',
+ '--cacheinfo',
+ '100644',
+ blob,
+ HOSTED_ADVERSARIAL_FILENAME
+ ])
+ await git(root, ['commit', '-q', '-m', 'Initial fixture'])
+ await git(root, ['branch', '-m', HOSTED_ADVERSARIAL_WORKSPACE_ROW])
+ return {
+ root,
+ workspaceName: path.basename(root),
+ workspaceRowName: HOSTED_ADVERSARIAL_WORKSPACE_ROW,
+ filename: HOSTED_ADVERSARIAL_FILENAME,
+ content: HOSTED_ADVERSARIAL_CONTENT
+ }
+ } catch (error) {
+ await removeHostedAdversarialRepositoryFixture({ root })
+ throw error
+ }
+}
+
+export async function removeHostedAdversarialRepositoryFixture(fixture) {
+ if (!fixture?.root || path.basename(fixture.root).startsWith(fixturePrefix) === false) {
+ throw new Error('Refusing to remove an invalid adversarial repository fixture')
+ }
+ await rm(fixture.root, { recursive: true, force: true })
+}
+
+export async function readHostedAdversarialRepositoryContent(fixture) {
+ return `${await git(fixture.root, ['show', `HEAD:${fixture.filename}`])}\n`
+}
+
+async function git(cwd, args) {
+ const { stdout } = await execFileAsync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 })
+ return stdout.trim()
+}
diff --git a/mobile/scripts/hosted-android-emulator-session.mjs b/mobile/scripts/hosted-android-emulator-session.mjs
index 371e32f6757..7d8dc541b87 100644
--- a/mobile/scripts/hosted-android-emulator-session.mjs
+++ b/mobile/scripts/hosted-android-emulator-session.mjs
@@ -176,20 +176,24 @@ export async function stopHostedAndroidApp(adb) {
await runAndroidAdb(adb, ['shell', 'am', 'force-stop', packageName]).catch(() => {})
}
-export function findHostedAndroidBridgeLogFailures(logcat) {
- return logcat
- .split(/\r?\n/u)
- .filter((line) =>
- /FATAL EXCEPTION|Call to function 'ExpoMobileWebShell\.[^']+' has been rejected|mobile_web_shell_view_unavailable|Cannot convert .* to a Kotlin type|ClassCastException.*MobileWebShellView/iu.test(
- line
- )
+export function findHostedAndroidBridgeLogFailures(logcat, appPid) {
+ return logcat.split(/\r?\n/u).filter((line) => {
+ const fatal = /FATAL EXCEPTION/iu.test(line)
+ if (fatal && appPid && !line.includes(`(${appPid})`)) {
+ return false
+ }
+ return /FATAL EXCEPTION|Call to function 'ExpoMobileWebShell\.[^']+' has been rejected|mobile_web_shell_view_unavailable|Cannot convert .* to a Kotlin type|ClassCastException.*MobileWebShellView/iu.test(
+ line
)
+ })
}
export async function assertHostedAndroidBridgeLogClean(adb) {
- const failures = findHostedAndroidBridgeLogFailures(
- await runAndroidAdb(adb, ['logcat', '-d', '-v', 'brief'])
- )
+ const [logcat, appPid] = await Promise.all([
+ runAndroidAdb(adb, ['logcat', '-d', '-v', 'brief']),
+ runAndroidAdb(adb, ['shell', 'pidof', packageName])
+ ])
+ const failures = findHostedAndroidBridgeLogFailures(logcat, appPid)
if (failures.length > 0) {
throw new Error(`Android bridge emitted errors:\n${failures.slice(0, 16).join('\n')}`)
}
diff --git a/mobile/scripts/hosted-android-privacy-audit.mjs b/mobile/scripts/hosted-android-privacy-audit.mjs
index c9bd604546c..c96180f0787 100644
--- a/mobile/scripts/hosted-android-privacy-audit.mjs
+++ b/mobile/scripts/hosted-android-privacy-audit.mjs
@@ -96,10 +96,11 @@ function isExpectedDebugWebSocketUrl(value, expected) {
return true
}
const keys = [...url.searchParams.keys()].sort()
+ const debugPorts = new Set([String(expected.devServerPort), '8081'])
return (
url.protocol === 'ws:' &&
['10.0.2.2', '127.0.0.1'].includes(url.hostname) &&
- url.port === String(expected.devServerPort) &&
+ debugPorts.has(url.port) &&
url.pathname === '/message' &&
!url.hash &&
keys.join(',') === 'app,clientid,device' &&
diff --git a/mobile/scripts/hosted-ios-adversarial-content.mjs b/mobile/scripts/hosted-ios-adversarial-content.mjs
new file mode 100644
index 00000000000..66f47d6936c
--- /dev/null
+++ b/mobile/scripts/hosted-ios-adversarial-content.mjs
@@ -0,0 +1,67 @@
+import { execFile } from 'node:child_process'
+import process from 'node:process'
+import { promisify } from 'node:util'
+import { HOSTED_ADVERSARIAL_CONTENT_MARKER } from './hosted-adversarial-repository-fixture.mjs'
+import {
+ captureHostedWebViewAdversarialObservation,
+ hostedWebViewAdversarialContentObservations
+} from './hosted-webview-adversarial-content.mjs'
+import { readHostedWebViewTextPoint } from './hosted-webview-cdp-session.mjs'
+import { tapHostedIosPoint } from './hosted-ios-emulator-accessibility.mjs'
+import { registerWorktreeForPairingRuntime } from './start-emulator-pairing-runtime.mjs'
+
+const execFileAsync = promisify(execFile)
+
+export async function registerHostedIosAdversarialRepository(
+ { fixture, orcaCli, pairingRuntimeUserDataPath },
+ runCli = execFileAsync
+) {
+ const env = {
+ ...process.env,
+ ORCA_DEV_USER_DATA_PATH: pairingRuntimeUserDataPath,
+ ORCA_USER_DATA_PATH: pairingRuntimeUserDataPath
+ }
+ await registerWorktreeForPairingRuntime({ env }, fixture.root, {
+ logStep: () => {},
+ logSuccess: () => {},
+ orca: async (args, options) => {
+ const result = await runCli(orcaCli, args, {
+ cwd: options.cwd,
+ encoding: 'utf8',
+ env: options.env,
+ timeout: options.timeout
+ })
+ return {
+ stdout: String(result.stdout).trim(),
+ stderr: String(result.stderr).trim()
+ }
+ }
+ })
+}
+
+export function createHostedIosAdversarialContentInspector({ emulator, fixture, timeoutMs }) {
+ const observations = []
+ return {
+ async inspect({ document, phase }) {
+ if (phase === 'sessionDiff') {
+ const point = await readHostedWebViewTextPoint(document, fixture.filename)
+ await tapHostedIosPoint(emulator, point)
+ await delay(250)
+ }
+ observations.push(
+ await captureHostedWebViewAdversarialObservation({
+ document,
+ expectedMarker: phase === 'sessionDiff' ? HOSTED_ADVERSARIAL_CONTENT_MARKER : undefined,
+ timeoutMs: Math.min(timeoutMs, 15_000)
+ })
+ )
+ },
+ evidence() {
+ return hostedWebViewAdversarialContentObservations(observations)
+ }
+ }
+}
+
+function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/mobile/scripts/hosted-ios-mobile-launcher.mjs b/mobile/scripts/hosted-ios-mobile-launcher.mjs
new file mode 100644
index 00000000000..79a4f1cd8b3
--- /dev/null
+++ b/mobile/scripts/hosted-ios-mobile-launcher.mjs
@@ -0,0 +1,74 @@
+import { spawn } from 'node:child_process'
+import path from 'node:path'
+import process from 'node:process'
+
+export function startHostedIosMobileLauncher({
+ deviceUdid,
+ emulatorControlUserDataPath,
+ orcaCli,
+ runtimeDirectory,
+ worktree
+}) {
+ return spawn(
+ process.execPath,
+ [
+ path.join(worktree, 'mobile', 'scripts', 'start-emulator.mjs'),
+ '--worktree',
+ worktree,
+ '--device',
+ deviceUdid,
+ '--wait-for-ready'
+ ],
+ {
+ cwd: worktree,
+ env: {
+ ...process.env,
+ ORCA_CLI: orcaCli,
+ ORCA_E2E_MOBILE_AUTO_SELECT_PAIRED_HOST: '1',
+ ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE: '1',
+ ORCA_E2E_MOBILE_RUN_DIRECTORY: path.join(runtimeDirectory, 'paired-host'),
+ ORCA_E2E_MOBILE_RESTART_HOLD_MS: '2000',
+ ORCA_E2E_MOBILE_EMULATOR_CONTROL_USER_DATA_PATH: emulatorControlUserDataPath
+ },
+ stdio: ['ignore', 'pipe', 'pipe']
+ }
+ )
+}
+
+export function waitForHostedIosMobileLauncher(child, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ let outputTail = ''
+ let settled = false
+ const timer = setTimeout(() => {
+ finish(new Error(`Mobile launcher timed out.\n${outputTail}`))
+ }, timeoutMs)
+ const consume = (chunk, target) => {
+ const text = String(chunk)
+ target.write(text)
+ outputTail = (outputTail + text).slice(-32 * 1024)
+ if (outputTail.includes('Setup complete!')) {
+ finish()
+ }
+ }
+ const finish = (error) => {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(timer)
+ child.off('exit', handleExit)
+ if (error) {
+ reject(error)
+ } else {
+ resolve()
+ }
+ }
+ const handleExit = (code) => {
+ finish(new Error(`Mobile launcher exited with code ${code}.\n${outputTail}`))
+ }
+ child.stdout.on('data', (chunk) => consume(chunk, process.stdout))
+ child.stderr.on('data', (chunk) => consume(chunk, process.stderr))
+ child.once('error', finish)
+ child.once('exit', handleExit)
+ })
+}
diff --git a/mobile/scripts/hosted-ios-source-control-review-journey.mjs b/mobile/scripts/hosted-ios-source-control-review-journey.mjs
index f18533f1571..981ed4a1731 100644
--- a/mobile/scripts/hosted-ios-source-control-review-journey.mjs
+++ b/mobile/scripts/hosted-ios-source-control-review-journey.mjs
@@ -22,6 +22,7 @@ export async function verifyHostedSourceControlReviewJourney({
sessionDocument,
timeoutMs,
expectedSessionDiffText = '2 tabs',
+ inspectChangedContent,
tapPoint = tapHostedJourneyPoint
}) {
const sourceControl = await journeyStep('wait for Source Control route', () =>
@@ -48,6 +49,11 @@ export async function verifyHostedSourceControlReviewJourney({
if (!changedFileLabel) {
throw new Error('Source Control has no changed file available for Review.')
}
+ if (inspectChangedContent) {
+ await journeyStep('inspect Source Control content', () =>
+ inspectChangedContent({ phase: 'sourceControl', document: sourceControl })
+ )
+ }
if (nativeBaselines) {
sourceState = await journeyStep('wait for stable Source Control parity state', () =>
waitForSourceControlParityState(sourceControl, timeoutMs, sourceState)
@@ -77,6 +83,11 @@ export async function verifyHostedSourceControlReviewJourney({
timeoutMs
})
)
+ if (inspectChangedContent) {
+ await journeyStep('inspect Session diff content', () =>
+ inspectChangedContent({ phase: 'sessionDiff', document: sessionDiff })
+ )
+ }
await journeyStep('open standalone Review route', () =>
navigateHostedWebViewRoute(sessionDiff, standaloneReviewRoute(sourceState.href))
)
@@ -94,6 +105,11 @@ export async function verifyHostedSourceControlReviewJourney({
throw new Error(`Review is missing ${label}.`)
}
}
+ if (inspectChangedContent) {
+ await journeyStep('inspect Review content', () =>
+ inspectChangedContent({ phase: 'review', document: review })
+ )
+ }
const hostedReview = nativeBaselines
? await journeyStep('capture Review parity', () =>
captureHostedSourceControlReviewScreen({
@@ -150,7 +166,7 @@ async function openSourceControlRoute({
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const point = await readHostedWebViewControlPoint(sessionDocument, 'Open source control')
- await tapPoint(emulator, point, 'Open source control', attempt)
+ await tapPoint(emulator, point, 'Open source control', attempt, sessionDocument)
return await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Source Control',
@@ -177,7 +193,7 @@ async function openSessionDiffRoute({
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const point = await readHostedWebViewControlPoint(sourceControl, label)
- await tapPoint(emulator, point, label, attempt)
+ await tapPoint(emulator, point, label, attempt, sourceControl)
} catch (error) {
lastError = error
continue
diff --git a/mobile/scripts/hosted-webview-adversarial-content.mjs b/mobile/scripts/hosted-webview-adversarial-content.mjs
new file mode 100644
index 00000000000..3befecaede9
--- /dev/null
+++ b/mobile/scripts/hosted-webview-adversarial-content.mjs
@@ -0,0 +1,108 @@
+import { WebSocket } from 'ws'
+import {
+ HOSTED_ADVERSARIAL_CONTENT,
+ HOSTED_ADVERSARIAL_CONTENT_MARKER,
+ HOSTED_ADVERSARIAL_FILENAME,
+ HOSTED_ADVERSARIAL_FILENAME_MARKER
+} from './hosted-adversarial-repository-fixture.mjs'
+import {
+ evaluateHostedDocumentWithRetry,
+ readHostedWebViewState
+} from './hosted-webview-cdp-session.mjs'
+
+const executionExpression = `JSON.stringify({
+ filenameExecuted: Boolean(globalThis.${HOSTED_ADVERSARIAL_FILENAME_MARKER}),
+ contentExecuted: Boolean(globalThis.${HOSTED_ADVERSARIAL_CONTENT_MARKER}),
+ injectedImageCount: [...document.images].filter((image) => image.getAttribute('src') === 'x').length
+})`
+
+export async function verifyHostedWebViewAdversarialContent({
+ document,
+ documents,
+ WebSocketCtor = WebSocket
+}) {
+ const targets = documents ?? (document ? [document] : [])
+ if (targets.length === 0) {
+ throw new Error('Hosted adversarial content requires a document')
+ }
+ const observations = await Promise.all(
+ targets.map((target) => readHostedWebViewAdversarialContent(target, WebSocketCtor))
+ )
+ return hostedWebViewAdversarialContentObservations(observations)
+}
+
+export async function readHostedWebViewAdversarialContent(document, WebSocketCtor = WebSocket) {
+ const [state, executionValue] = await Promise.all([
+ readHostedWebViewState(document, WebSocketCtor),
+ evaluateHostedDocumentWithRetry(document, executionExpression, WebSocketCtor)
+ ])
+ return { state, execution: JSON.parse(executionValue) }
+}
+
+export async function captureHostedWebViewAdversarialObservation({
+ document,
+ expectedMarker,
+ timeoutMs,
+ WebSocketCtor = WebSocket
+}) {
+ const deadline = Date.now() + timeoutMs
+ let observation
+ do {
+ observation = await readHostedWebViewAdversarialContent(document, WebSocketCtor)
+ const text = `${observation.state.bodyText}\n${observation.state.labels.join('\n')}`
+ if (!expectedMarker || text.includes(expectedMarker)) {
+ return observation
+ }
+ await delay(250)
+ } while (Date.now() < deadline)
+ throw new Error(`Hosted adversarial marker was not rendered: ${expectedMarker}`)
+}
+
+export function hostedWebViewAdversarialContentObservations(observations) {
+ for (const { execution } of observations) {
+ if (
+ execution?.filenameExecuted !== false ||
+ execution?.contentExecuted !== false ||
+ execution?.injectedImageCount !== 0
+ ) {
+ throw new Error(`Hosted adversarial content executed: ${JSON.stringify(execution)}`)
+ }
+ }
+ return hostedWebViewAdversarialContentEvidence({
+ text: observations
+ .map(({ state }) => `${state.bodyText}\n${state.labels.join('\n')}`)
+ .join('\n'),
+ execution: {
+ filenameExecuted: false,
+ contentExecuted: false,
+ injectedImageCount: 0
+ }
+ })
+}
+
+export function hostedWebViewAdversarialContentEvidence({ text, execution }) {
+ const filenameRendered = text.includes(HOSTED_ADVERSARIAL_FILENAME)
+ const diffRendered = text.includes(HOSTED_ADVERSARIAL_CONTENT)
+ if (!filenameRendered || !diffRendered) {
+ throw new Error(
+ `Hosted adversarial content was not rendered: filename=${filenameRendered} diff=${diffRendered}`
+ )
+ }
+ if (
+ execution?.filenameExecuted !== false ||
+ execution?.contentExecuted !== false ||
+ execution?.injectedImageCount !== 0
+ ) {
+ throw new Error(`Hosted adversarial content executed: ${JSON.stringify(execution)}`)
+ }
+ return {
+ filenameRenderedAsText: true,
+ diffRenderedAsText: true,
+ injectedImageCount: 0,
+ scriptMarkersExecuted: false
+ }
+}
+
+function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/mobile/scripts/hosted-webview-cdp-session.mjs b/mobile/scripts/hosted-webview-cdp-session.mjs
index a072f9a80b6..d9eb62afe62 100644
--- a/mobile/scripts/hosted-webview-cdp-session.mjs
+++ b/mobile/scripts/hosted-webview-cdp-session.mjs
@@ -236,7 +236,7 @@ export async function readHostedWebViewTextPoint(
rect.left < innerWidth;
};
const matches = Array.from(document.querySelectorAll('body *')).filter((candidate) =>
- candidate.children.length === 0 && isVisible(candidate) && (
+ candidate.children.length === 0 && (${options.reveal === true} || isVisible(candidate)) && (
${
options.ignoreCase === true
? "String(candidate.textContent ?? '').trim().toLocaleLowerCase()"
@@ -246,6 +246,9 @@ export async function readHostedWebViewTextPoint(
);
const element = matches[${options.occurrence ?? 0}];
if (!element) return '';
+ if (${options.reveal === true}) {
+ element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ }
const rect = element.getBoundingClientRect();
const screenWidth = Number(screen.width);
const screenHeight = Number(screen.height);
diff --git a/mobile/scripts/hosted-webview-simulator-e2e-options.mjs b/mobile/scripts/hosted-webview-simulator-e2e-options.mjs
index 57e17b5f5d5..3de19a24ac1 100644
--- a/mobile/scripts/hosted-webview-simulator-e2e-options.mjs
+++ b/mobile/scripts/hosted-webview-simulator-e2e-options.mjs
@@ -1,11 +1,12 @@
import process from 'node:process'
const usage =
- 'Usage: node scripts/run-hosted-webview-simulator-e2e.mjs [--device ] [--timeout-ms ] [--expected-build ] [--accounts-only] [--security-only] [--isolation-only] [--clipboard-image-only] [--photos-revocation-only] [--files-preview-only] [--native-settings-only] [--source-control-only] [--skip-native-build] [--reuse-native-install]'
+ 'Usage: node scripts/run-hosted-webview-simulator-e2e.mjs [--device ] [--timeout-ms ] [--expected-build ] [--accounts-only] [--security-only] [--isolation-only] [--clipboard-image-only] [--photos-revocation-only] [--files-preview-only] [--native-settings-only] [--source-control-only] [--adversarial-content] [--skip-native-build] [--reuse-native-install]'
export function parseHostedWebViewSimulatorE2eOptions(args) {
const parsed = {
accountsOnly: false,
+ adversarialContent: false,
clipboardImageOnly: false,
device: 'iPhone 17 Pro',
expectedBuild: undefined,
@@ -44,6 +45,8 @@ export function parseHostedWebViewSimulatorE2eOptions(args) {
parsed.nativeSettingsOnly = true
} else if (args[index] === '--source-control-only') {
parsed.sourceControlOnly = true
+ } else if (args[index] === '--adversarial-content') {
+ parsed.adversarialContent = true
} else if (args[index] === '--skip-native-build') {
parsed.skipNativeBuild = true
} else if (args[index] === '--reuse-native-install') {
@@ -67,6 +70,7 @@ export function parseHostedWebViewSimulatorE2eOptions(args) {
if (
[
parsed.accountsOnly,
+ parsed.adversarialContent,
parsed.clipboardImageOnly,
parsed.isolationOnly,
parsed.securityOnly,
@@ -80,5 +84,6 @@ export function parseHostedWebViewSimulatorE2eOptions(args) {
}
parsed.securityOnly ||=
parsed.clipboardImageOnly || parsed.isolationOnly || parsed.photosRevocationOnly
+ parsed.sourceControlOnly ||= parsed.adversarialContent
return parsed
}
diff --git a/mobile/scripts/hosted-webview-workspace-activation.mjs b/mobile/scripts/hosted-webview-workspace-activation.mjs
index 5ed9e83f629..db3ab1601bd 100644
--- a/mobile/scripts/hosted-webview-workspace-activation.mjs
+++ b/mobile/scripts/hosted-webview-workspace-activation.mjs
@@ -19,6 +19,13 @@ export async function activateHostedWorkspaceRow(
activeDocument = await resolveDocument()
continue
}
+ if (isMissingControl(error) && resolveDocument) {
+ const resolvedDocument = await resolveDocument()
+ if (resolvedDocument.href !== activeDocument.href) {
+ return
+ }
+ activeDocument = resolvedDocument
+ }
if (!isMissingControl(error)) {
throw error
}
@@ -33,7 +40,8 @@ async function activateHostedWorkspaceRowOnce(document, workspaceName, activateC
try {
await activateControl(document, {
kind: 'label',
- value: `Open ${workspaceName}`
+ value: `Open ${workspaceName}`,
+ reveal: true
})
return
} catch (error) {
@@ -46,7 +54,8 @@ async function activateHostedWorkspaceRowOnce(document, workspaceName, activateC
kind: 'text',
value: workspaceName,
ignoreCase: true,
- occurrence: 1
+ occurrence: 1,
+ reveal: true
})
} catch (error) {
if (!isMissingControl(error)) {
@@ -55,7 +64,8 @@ async function activateHostedWorkspaceRowOnce(document, workspaceName, activateC
await activateControl(document, {
kind: 'text',
value: workspaceName,
- ignoreCase: true
+ ignoreCase: true,
+ reveal: true
})
await delay(250)
try {
@@ -63,7 +73,8 @@ async function activateHostedWorkspaceRowOnce(document, workspaceName, activateC
kind: 'text',
value: workspaceName,
ignoreCase: true,
- occurrence: 1
+ occurrence: 1,
+ reveal: true
})
} catch (fallbackError) {
if (isStaleDocument(fallbackError)) {
diff --git a/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs b/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs
index 0a779989798..8e0511de535 100644
--- a/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs
+++ b/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs
@@ -6,6 +6,11 @@ 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
@@ -38,6 +43,10 @@ import {
} 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'
@@ -72,6 +81,7 @@ async function main() {
let probe
let inspector
let exitInfoBaseline
+ let adversarialFixture
const reversePorts = new Set()
try {
await stage('Android emulator', () => runAndroidAdb(adb, ['get-state']))
@@ -82,6 +92,12 @@ async function main() {
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({
@@ -94,9 +110,12 @@ async function main() {
logSuccess: () => {}
})
)
- runtime.env.ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE = '1'
+ if (!options.adversarialContent) {
+ runtime.env.ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE = '1'
+ }
+ const testWorkspace = adversarialFixture?.root ?? worktree
await stage('test workspace registration', () =>
- registerWorktreeForPairingRuntime(runtime, worktree, {
+ registerWorktreeForPairingRuntime(runtime, testWorkspace, {
orca: runOrca,
logStep: () => {},
logSuccess: () => {}
@@ -134,7 +153,8 @@ async function main() {
timeoutMs: options.timeoutMs
})
)
- const expectedWorkspace = path.basename(worktree)
+ const expectedWorkspace = path.basename(testWorkspace)
+ const workspaceRowName = adversarialFixture?.workspaceRowName ?? expectedWorkspace
const workspaceDocument = await stage('hosted workspace data', () =>
waitForVisibleHostedWebView({
discoveryUrl,
@@ -142,23 +162,20 @@ async function main() {
timeoutMs: options.timeoutMs
})
)
- let privacyIsolation = null
- if (options.securityOnly) {
- privacyIsolation = await stage('workspace privacy isolation probe', () =>
- verifyHostedWebViewPrivacyIsolation({ document: workspaceDocument })
- )
- }
+ const privacyIsolation = await stage('workspace privacy isolation probe', () =>
+ verifyHostedWebViewPrivacyIsolation({ document: workspaceDocument })
+ )
await stage('workspace activation', async () => {
try {
await activateHostedWorkspaceRow(
workspaceDocument,
- expectedWorkspace,
+ workspaceRowName,
(document, target) => activateAndroidWorkspaceControl(emulator, document, target),
Math.min(options.timeoutMs, 15_000),
() =>
waitForVisibleHostedWebView({
discoveryUrl,
- expectedText: 'Orca Desktop',
+ expectedText: workspaceRowName,
timeoutMs: options.timeoutMs
})
)
@@ -194,29 +211,69 @@ async function main() {
})
)
let agentHistory = null
+ let adversarialContent = null
+ const adversarialObservations = []
let sourceControlReview = null
let isolationDocument = sessionDocument
if (!options.securityOnly) {
- const agentHistoryResult = await stage('Agent History journey', () =>
- verifyHostedAndroidAgentHistoryJourney({
- discoveryUrl,
- emulator,
- sessionDocument,
- timeoutMs: options.timeoutMs
- })
- )
- const { returnedSessionDocument, ...evidence } = agentHistoryResult
- agentHistory = evidence
+ 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: returnedSessionDocument,
- expectedSessionDiffText: '3 tabs',
+ 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',
@@ -243,9 +300,6 @@ async function main() {
probeId: probe.token
})
)
- privacyIsolation ??= await stage('privacy isolation probe', () =>
- verifyHostedWebViewPrivacyIsolation({ document: isolationDocument })
- )
await delay(500)
if (probe.observations.length > 0) {
throw new Error(
@@ -269,6 +323,7 @@ async function main() {
pid: inspector.pid,
workspace: expectedWorkspace,
agentHistory,
+ adversarialContent,
sourceControlReview,
networkIsolation,
navigationIsolation,
@@ -292,6 +347,9 @@ async function main() {
await metro?.stop()
await runtime?.stop({ shutdownDaemon: true })
await probe?.stop()
+ if (adversarialFixture) {
+ await removeHostedAdversarialRepositoryFixture(adversarialFixture)
+ }
}
}
@@ -335,6 +393,14 @@ 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
@@ -342,17 +408,26 @@ async function activateAndroidWorkspaceControl(emulator, document, target) {
await tapHostedAndroidPoint(emulator, point)
}
-async function tapHostedAndroidJourneyControl(emulator, point, label) {
- if (label) {
+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')) {
@@ -388,6 +463,7 @@ function parseOptions(args) {
const result = {
adb: null,
apk: defaultApk,
+ adversarialContent: false,
securityOnly: false,
skipNativeBuild: false,
timeoutMs: 90_000
@@ -400,6 +476,8 @@ function parseOptions(args) {
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') {
@@ -413,6 +491,9 @@ function parseOptions(args) {
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
}
diff --git a/mobile/scripts/run-hosted-webview-simulator-e2e.mjs b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs
index ca0f6805294..a3f949ce457 100644
--- a/mobile/scripts/run-hosted-webview-simulator-e2e.mjs
+++ b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs
@@ -1,6 +1,6 @@
#!/usr/bin/env node
-import { execFile, spawn } from 'node:child_process'
+import { execFile } from 'node:child_process'
import { mkdirSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
@@ -8,6 +8,10 @@ import process from 'node:process'
import { promisify } from 'node:util'
import { startCdpServer } from 'inspect-webkit'
import { resolveEmulatorOrcaCli } from './emulator-orca-cli-selection.mjs'
+import {
+ createHostedAdversarialRepositoryFixture,
+ removeHostedAdversarialRepositoryFixture
+} from './hosted-adversarial-repository-fixture.mjs'
import { stopHostedChildProcess } from './hosted-child-process-shutdown.mjs'
import { parseHostedWebViewSimulatorE2eOptions } from './hosted-webview-simulator-e2e-options.mjs'
import {
@@ -24,6 +28,10 @@ import {
import { verifyHostedWebViewExecutableIsolation } from './hosted-webview-executable-isolation.mjs'
import { verifyHostedWebViewPrivacyIsolation } from './hosted-webview-privacy-isolation.mjs'
import { captureNativeAgentHistoryBaseline } from './hosted-ios-agent-history-parity.mjs'
+import {
+ createHostedIosAdversarialContentInspector,
+ registerHostedIosAdversarialRepository
+} from './hosted-ios-adversarial-content.mjs'
import {
captureHostedCoreRouteParity,
captureNativeCoreRouteBaselines
@@ -38,6 +46,10 @@ import {
} from './hosted-ios-workspace-parity.mjs'
import { verifyHostedAgentHistoryJourney } from './hosted-ios-agent-history-journey.mjs'
import { openHostedIosHybridRoute } from './hosted-ios-hybrid-route-handoff.mjs'
+import {
+ startHostedIosMobileLauncher,
+ waitForHostedIosMobileLauncher
+} from './hosted-ios-mobile-launcher.mjs'
import { verifyHostedNativeTerminalSettingsHandoff } from './hosted-ios-native-settings-handoff.mjs'
import { verifyHostedSourceControlReviewJourney } from './hosted-ios-source-control-review-journey.mjs'
import { captureNativeSourceControlReviewBaselines } from './hosted-ios-source-control-review-parity.mjs'
@@ -56,7 +68,6 @@ import { hostedIosSimulatorAppPreparation } from './hosted-ios-simulator-app-pre
const execFileAsync = promisify(execFile)
const worktree = path.resolve(import.meta.dirname, '../..')
-const launcherPath = path.join(worktree, 'mobile', 'scripts', 'start-emulator.mjs')
const options = parseHostedWebViewSimulatorE2eOptions(process.argv.slice(2))
const runtimeDirectory = resolveHostedWebViewRuntimeDirectory({
worktree,
@@ -81,7 +92,12 @@ async function main() {
let networkProbe = null
let emulatorController = null
let nativeAppPath = null
+ let adversarialFixture = null
+ let adversarialInspector = null
try {
+ if (options.adversarialContent) {
+ adversarialFixture = await createHostedAdversarialRepositoryFixture()
+ }
networkProbe = await startHostedIosWebViewSecurityProbe()
await bootSimulator(deviceUdid)
emulatorController = await startHostedIosEmulatorController({
@@ -97,8 +113,14 @@ async function main() {
resetHostedIosPhotosPermission(deviceUdid)
)
}
- launcher = startMobileLauncher(deviceUdid, emulatorController.userData)
- await waitForLauncher(launcher, options.timeoutMs)
+ launcher = startHostedIosMobileLauncher({
+ deviceUdid,
+ emulatorControlUserDataPath: emulatorController.userData,
+ orcaCli: orcaSelection.command,
+ runtimeDirectory,
+ worktree
+ })
+ await waitForHostedIosMobileLauncher(launcher, options.timeoutMs)
const emulator = {
deviceUdid,
orcaCli: orcaSelection.command,
@@ -109,6 +131,20 @@ async function main() {
const nativeOnboarding = await evidenceStep('native onboarding', () =>
completeHostedIosNativeOnboarding(emulator, expectedWorkspace, options.timeoutMs)
)
+ if (adversarialFixture) {
+ await evidenceStep('adversarial repository registration', () =>
+ registerHostedIosAdversarialRepository({
+ fixture: adversarialFixture,
+ orcaCli: orcaSelection.command,
+ pairingRuntimeUserDataPath: path.join(runtimeDirectory, 'paired-host', 'userData')
+ })
+ )
+ adversarialInspector = createHostedIosAdversarialContentInspector({
+ emulator,
+ fixture: adversarialFixture,
+ timeoutMs: options.timeoutMs
+ })
+ }
const nativeWorkspace =
options.securityOnly ||
options.filesPreviewOnly ||
@@ -140,6 +176,7 @@ async function main() {
)
const nativeCoreRoutes =
options.accountsOnly ||
+ options.adversarialContent ||
options.securityOnly ||
options.filesPreviewOnly ||
options.nativeSettingsOnly ||
@@ -171,6 +208,7 @@ async function main() {
)
const nativeSourceControlReview =
options.accountsOnly ||
+ options.adversarialContent ||
options.securityOnly ||
options.filesPreviewOnly ||
options.nativeSettingsOnly
@@ -211,6 +249,11 @@ async function main() {
expectedText: 'Orca Desktop',
timeoutMs: options.timeoutMs
})
+ const workspacePrivacyIsolation = options.adversarialContent
+ ? await evidenceStep('workspace privacy isolation probe', () =>
+ verifyHostedWebViewPrivacyIsolation({ document: workspaceDocument })
+ )
+ : null
const securityJourney = {
deviceUdid,
discoveryUrl,
@@ -339,13 +382,13 @@ async function main() {
if (options.sourceControlOnly) {
await activateHostedWorkspaceRow(
workspaceDocument,
- expectedWorkspace,
+ adversarialFixture?.workspaceRowName ?? expectedWorkspace,
activateHostedWebViewControl,
options.timeoutMs,
() =>
waitForVisibleHostedWebView({
discoveryUrl: `http://127.0.0.1:${inspectorPort}`,
- expectedText: 'Orca Desktop',
+ expectedText: adversarialFixture?.workspaceRowName ?? 'Orca Desktop',
timeoutMs: options.timeoutMs
})
)
@@ -363,11 +406,17 @@ async function main() {
emulator,
expectedSessionDiffText: options.sourceControlOnly ? '2 tabs' : '3 tabs',
nativeBaselines: nativeSourceControlReview,
+ inspectChangedContent: adversarialInspector?.inspect,
runtimeDirectory,
sessionDocument,
timeoutMs: options.timeoutMs
})
})
+ const adversarialContent = adversarialInspector
+ ? await evidenceStep('adversarial filename and diff presentation', () =>
+ adversarialInspector.evidence()
+ )
+ : null
const securityDocument =
options.securityOnly && terminalDeviceInput
? (terminalDeviceInput.terminalClipboardImagePaste?.sessionDocument ??
@@ -399,9 +448,11 @@ async function main() {
probeId: networkProbe.token
})
)
- const privacyIsolation = await evidenceStep('privacy isolation probe', () =>
- verifyHostedWebViewPrivacyIsolation({ document: securityDocument })
- )
+ const privacyIsolation =
+ workspacePrivacyIsolation ??
+ (await evidenceStep('privacy isolation probe', () =>
+ verifyHostedWebViewPrivacyIsolation({ document: securityDocument })
+ ))
await delay(500)
if (networkProbe.observations.length > 0) {
throw new Error(
@@ -436,7 +487,8 @@ async function main() {
agentHistory: historyEvidence,
coreRouteParity: hostedCoreRoutes?.evidence ?? null,
filesPreviewParity: hostedFilesPreview?.evidence ?? null,
- sourceControlReview
+ sourceControlReview,
+ adversarialContent
},
null,
2
@@ -445,6 +497,9 @@ async function main() {
} finally {
inspector?.stop()
await stopHostedChildProcess(launcher)
+ if (adversarialFixture) {
+ await removeHostedAdversarialRepositoryFixture(adversarialFixture)
+ }
await emulatorController?.stop()
await clearHostedIosWebViewSecurityProbe(deviceUdid)
await networkProbe?.stop()
@@ -516,64 +571,6 @@ async function bootSimulator(deviceUdid) {
await execFileAsync('xcrun', ['simctl', 'bootstatus', deviceUdid, '-b'])
}
-function startMobileLauncher(deviceUdid, emulatorControlUserDataPath) {
- return spawn(
- process.execPath,
- [launcherPath, '--worktree', worktree, '--device', deviceUdid, '--wait-for-ready'],
- {
- cwd: worktree,
- env: {
- ...process.env,
- ORCA_CLI: orcaSelection.command,
- ORCA_E2E_MOBILE_AUTO_SELECT_PAIRED_HOST: '1',
- ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE: '1',
- ORCA_E2E_MOBILE_RUN_DIRECTORY: path.join(runtimeDirectory, 'paired-host'),
- ORCA_E2E_MOBILE_RESTART_HOLD_MS: '2000',
- ORCA_E2E_MOBILE_EMULATOR_CONTROL_USER_DATA_PATH: emulatorControlUserDataPath
- },
- stdio: ['ignore', 'pipe', 'pipe']
- }
- )
-}
-
-function waitForLauncher(child, timeoutMs) {
- return new Promise((resolve, reject) => {
- let outputTail = ''
- let settled = false
- const timer = setTimeout(() => {
- finish(new Error(`Mobile launcher timed out.\n${outputTail}`))
- }, timeoutMs)
- const consume = (chunk, target) => {
- const text = String(chunk)
- target.write(text)
- outputTail = (outputTail + text).slice(-32 * 1024)
- if (outputTail.includes('Setup complete!')) {
- finish()
- }
- }
- const finish = (error) => {
- if (settled) {
- return
- }
- settled = true
- clearTimeout(timer)
- child.off('exit', handleExit)
- if (error) {
- reject(error)
- } else {
- resolve()
- }
- }
- const handleExit = (code) => {
- finish(new Error(`Mobile launcher exited with code ${code}.\n${outputTail}`))
- }
- child.stdout.on('data', (chunk) => consume(chunk, process.stdout))
- child.stderr.on('data', (chunk) => consume(chunk, process.stderr))
- child.once('error', finish)
- child.once('exit', handleExit)
- })
-}
-
function findAvailableLoopbackPort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
diff --git a/mobile/src/mobile-web/hosted-android-emulator-session.test.ts b/mobile/src/mobile-web/hosted-android-emulator-session.test.ts
index 553d785a1d5..3d890734704 100644
--- a/mobile/src/mobile-web/hosted-android-emulator-session.test.ts
+++ b/mobile/src/mobile-web/hosted-android-emulator-session.test.ts
@@ -22,4 +22,15 @@ describe('hosted Android emulator session', () => {
`)
).toEqual([])
})
+
+ it('ignores automation-process fatals while retaining app-process fatals', () => {
+ const logcat = `
+ E/AndroidRuntime(20196): FATAL EXCEPTION: UiAutomation
+ E/AndroidRuntime(20200): FATAL EXCEPTION: main
+ `
+
+ expect(findHostedAndroidBridgeLogFailures(logcat, '20200')).toEqual([
+ ' E/AndroidRuntime(20200): FATAL EXCEPTION: main'
+ ])
+ })
})
diff --git a/mobile/src/mobile-web/hosted-android-privacy-audit.test.ts b/mobile/src/mobile-web/hosted-android-privacy-audit.test.ts
index f2278564db7..d2df19ad2db 100644
--- a/mobile/src/mobile-web/hosted-android-privacy-audit.test.ts
+++ b/mobile/src/mobile-web/hosted-android-privacy-audit.test.ts
@@ -50,18 +50,20 @@ describe('hosted Android privacy audit', () => {
[
'ws://10.0.2.2:8081/message?device=Pixel%209%20-%2016%20-%20API%2036&app=com.stably.orca.mobile&clientid=BridgelessDevSupportManager',
'ws://10.0.2.2:8081/message?device=Pixel%209%20-%2016%20-%20API%2036&app=com.stably.orca.mobile&clientid=DevLauncherBridgelessDevSupportManager',
+ 'ws://10.0.2.2:57999/message?device=Pixel%209&app=com.stably.orca.mobile&clientid=BridgelessDevSupportManager',
'ws://127.0.0.1:53250/socket-probe'
].join('\n'),
- { devServerPort: 8081, probePort: 53250 }
+ { devServerPort: 57999, probePort: 53250 }
)
- expect(evidence.expectedDebugWebSocketUrls).toBe(3)
+ expect(evidence.expectedDebugWebSocketUrls).toBe(4)
expect(evidence.counts.webSocketUrl).toBe(0)
})
it.each([
'ws://10.0.2.2:8081/message?device=Pixel&app=com.stably.orca.mobile&clientid=BridgelessDevSupportManager&token=secret',
'ws://10.0.2.2:8081/message?device=Pixel&app=other&clientid=BridgelessDevSupportManager',
+ 'ws://10.0.2.2:8082/message?device=Pixel&app=com.stably.orca.mobile&clientid=BridgelessDevSupportManager',
'wss://127.0.0.1:53250/socket-probe',
'ws://127.0.0.1:53250/socket-probe?token=secret'
])('rejects mutated debug WebSocket %s', (url) => {
diff --git a/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts b/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts
index 83fde69f11c..bf41d7c1ce9 100644
--- a/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts
+++ b/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts
@@ -122,6 +122,7 @@ describe('hosted iOS Source Control and Review journey', () => {
it('accepts a platform-specific native tap implementation', async () => {
const tapPoint = vi.fn().mockResolvedValue(undefined)
+ const inspectChangedContent = vi.fn().mockResolvedValue(undefined)
await verifyHostedSourceControlReviewJourney({
discoveryUrl: 'http://127.0.0.1:9222',
@@ -130,6 +131,7 @@ describe('hosted iOS Source Control and Review journey', () => {
href: 'orca-mobile-web://build/h/host/session/workspace'
},
expectedSessionDiffText: '3 tabs',
+ inspectChangedContent,
timeoutMs: 30_000,
tapPoint
})
@@ -139,6 +141,11 @@ describe('hosted iOS Source Control and Review journey', () => {
'Open source control',
'Open changed file mobile/app/index.tsx'
])
+ expect(inspectChangedContent.mock.calls.map((call) => call[0].phase)).toEqual([
+ 'sourceControl',
+ 'sessionDiff',
+ 'review'
+ ])
expect(mocks.waitForDocument).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ expectedText: '3 tabs' })
diff --git a/mobile/src/mobile-web/hosted-webview-adversarial-content.test.ts b/mobile/src/mobile-web/hosted-webview-adversarial-content.test.ts
new file mode 100644
index 00000000000..d4f01c006dc
--- /dev/null
+++ b/mobile/src/mobile-web/hosted-webview-adversarial-content.test.ts
@@ -0,0 +1,217 @@
+import { execFile } from 'node:child_process'
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ createHostedAdversarialRepositoryFixture,
+ HOSTED_ADVERSARIAL_CONTENT,
+ HOSTED_ADVERSARIAL_CONTENT_MARKER,
+ HOSTED_ADVERSARIAL_FILENAME,
+ HOSTED_ADVERSARIAL_FILENAME_MARKER,
+ HOSTED_ADVERSARIAL_WORKSPACE_ROW,
+ readHostedAdversarialRepositoryContent,
+ removeHostedAdversarialRepositoryFixture
+} from '../../scripts/hosted-adversarial-repository-fixture.mjs'
+import {
+ hostedWebViewAdversarialContentEvidence,
+ hostedWebViewAdversarialContentObservations,
+ verifyHostedWebViewAdversarialContent
+} from '../../scripts/hosted-webview-adversarial-content.mjs'
+
+const execFileAsync = promisify(execFile)
+const fixtures: Array>> = []
+const androidHarnessSource = readFileSync(
+ new URL('../../scripts/run-hosted-android-source-control-review-e2e.mjs', import.meta.url),
+ 'utf8'
+)
+const androidHarnessPath = fileURLToPath(
+ new URL('../../scripts/run-hosted-android-source-control-review-e2e.mjs', import.meta.url)
+)
+const iosHarnessSource = readFileSync(
+ new URL('../../scripts/run-hosted-webview-simulator-e2e.mjs', import.meta.url),
+ 'utf8'
+)
+
+afterEach(async () => {
+ await Promise.all(fixtures.splice(0).map(removeHostedAdversarialRepositoryFixture))
+})
+
+describe('hosted WebView adversarial content', () => {
+ it('creates one disposable hostile filename and diff', async () => {
+ const fixture = await createHostedAdversarialRepositoryFixture()
+ fixtures.push(fixture)
+
+ const status = await execFileAsync('git', ['status', '--short'], {
+ cwd: fixture.root,
+ encoding: 'utf8'
+ })
+ expect(status.stdout).toContain(HOSTED_ADVERSARIAL_FILENAME_MARKER)
+ const diff = await execFileAsync('git', ['diff', '--'], {
+ cwd: fixture.root,
+ encoding: 'utf8'
+ })
+ expect(diff.stdout).toContain(HOSTED_ADVERSARIAL_CONTENT_MARKER)
+ expect(await readHostedAdversarialRepositoryContent(fixture)).toBe(
+ `${HOSTED_ADVERSARIAL_CONTENT}\n`
+ )
+ const branch = await execFileAsync('git', ['symbolic-ref', '--short', 'HEAD'], {
+ cwd: fixture.root,
+ encoding: 'utf8'
+ })
+ expect(branch.stdout.trim()).toBe(HOSTED_ADVERSARIAL_WORKSPACE_ROW)
+ expect(fixture.workspaceRowName).toBe(HOSTED_ADVERSARIAL_WORKSPACE_ROW)
+ })
+
+ it('accepts literal markers without created elements or execution', () => {
+ expect(
+ hostedWebViewAdversarialContentEvidence({
+ text: `${HOSTED_ADVERSARIAL_FILENAME}\n${HOSTED_ADVERSARIAL_CONTENT}`,
+ execution: {
+ filenameExecuted: false,
+ contentExecuted: false,
+ injectedImageCount: 0
+ }
+ })
+ ).toEqual({
+ filenameRenderedAsText: true,
+ diffRenderedAsText: true,
+ injectedImageCount: 0,
+ scriptMarkersExecuted: false
+ })
+ })
+
+ it('aggregates literal markers across route documents', async () => {
+ const values = [
+ JSON.stringify({
+ href: 'https://orca-mobile-web.invalid/source-control',
+ bodyText: HOSTED_ADVERSARIAL_FILENAME,
+ labels: [],
+ placeholders: []
+ }),
+ JSON.stringify({
+ filenameExecuted: false,
+ contentExecuted: false,
+ injectedImageCount: 0
+ }),
+ JSON.stringify({
+ href: 'https://orca-mobile-web.invalid/session',
+ bodyText: HOSTED_ADVERSARIAL_CONTENT,
+ labels: [],
+ placeholders: []
+ }),
+ JSON.stringify({
+ filenameExecuted: false,
+ contentExecuted: false,
+ injectedImageCount: 0
+ })
+ ]
+ class FakeWebSocket {
+ private message: ((data: Buffer) => void) | undefined
+
+ once(event: string, listener: () => void) {
+ if (event === 'open') {
+ queueMicrotask(listener)
+ }
+ }
+
+ on(event: string, listener: (data: Buffer) => void) {
+ if (event === 'message') {
+ this.message = listener
+ }
+ }
+
+ send(payload: string) {
+ const id = JSON.parse(payload).id
+ const value = values.shift()
+ queueMicrotask(() => {
+ this.message?.(
+ Buffer.from(JSON.stringify({ id, result: { result: { value: value ?? '' } } }))
+ )
+ })
+ }
+
+ close() {}
+ }
+
+ await expect(
+ verifyHostedWebViewAdversarialContent({
+ documents: [
+ { webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/source' },
+ { webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/session' }
+ ],
+ WebSocketCtor: FakeWebSocket as never
+ })
+ ).resolves.toMatchObject({
+ filenameRenderedAsText: true,
+ diffRenderedAsText: true,
+ scriptMarkersExecuted: false
+ })
+ })
+
+ it('rejects missing literal markers and executed content', () => {
+ expect(() =>
+ hostedWebViewAdversarialContentEvidence({
+ text: HOSTED_ADVERSARIAL_FILENAME,
+ execution: {
+ filenameExecuted: false,
+ contentExecuted: false,
+ injectedImageCount: 0
+ }
+ })
+ ).toThrow('was not rendered')
+ expect(() =>
+ hostedWebViewAdversarialContentEvidence({
+ text: `${HOSTED_ADVERSARIAL_FILENAME}\n${HOSTED_ADVERSARIAL_CONTENT}`,
+ execution: {
+ filenameExecuted: false,
+ contentExecuted: true,
+ injectedImageCount: 1
+ }
+ })
+ ).toThrow('content executed')
+ })
+
+ it('rejects malformed execution observations without coercion', () => {
+ expect(() =>
+ hostedWebViewAdversarialContentObservations([
+ {
+ state: {
+ bodyText: `${HOSTED_ADVERSARIAL_FILENAME}\n${HOSTED_ADVERSARIAL_CONTENT}`,
+ labels: []
+ },
+ execution: {
+ filenameExecuted: 'false',
+ contentExecuted: false,
+ injectedImageCount: 0
+ }
+ }
+ ])
+ ).toThrow('content executed')
+ })
+
+ it('keeps the disposable corpus wired into the exact Android route gate', () => {
+ expect(androidHarnessSource).toContain('createHostedAdversarialRepositoryFixture')
+ expect(androidHarnessSource).toContain('captureHostedWebViewAdversarialObservation')
+ expect(androidHarnessSource).toContain('hostedWebViewAdversarialContentObservations')
+ expect(androidHarnessSource).toContain('removeHostedAdversarialRepositoryFixture')
+ })
+
+ it('rejects conflicting Android security journeys before device access', async () => {
+ await expect(
+ execFileAsync(process.execPath, [
+ androidHarnessPath,
+ '--adversarial-content',
+ '--security-only'
+ ])
+ ).rejects.toMatchObject({
+ stderr: expect.stringContaining('mutually exclusive')
+ })
+ })
+
+ it('keeps the same disposable corpus wired into the exact iOS route gate', () => {
+ expect(iosHarnessSource).toContain('registerHostedIosAdversarialRepository')
+ expect(iosHarnessSource).toContain('createHostedIosAdversarialContentInspector')
+ expect(iosHarnessSource).toContain('removeHostedAdversarialRepositoryFixture')
+ })
+})
diff --git a/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts b/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts
index e4c3b2c5b7d..33955f8bd33 100644
--- a/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts
+++ b/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts
@@ -296,13 +296,16 @@ describe('hosted WebView CDP target selection', () => {
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current' },
'Agent Session History',
fakeCdpConstructor(socket),
- { ignoreCase: true, occurrence: 1 }
+ { ignoreCase: true, occurrence: 1, reveal: true }
)
).resolves.toEqual({ x: 0.25, y: 0.125 })
expect(socket.evaluations[0]?.params.expression).toContain('getBoundingClientRect')
expect(socket.evaluations[0]?.params.expression).toContain("style.visibility !== 'hidden'")
expect(socket.evaluations[0]?.params.expression).toContain('toLocaleLowerCase')
expect(socket.evaluations[0]?.params.expression).toContain('matches[1]')
+ expect(socket.evaluations[0]?.params.expression).toContain(
+ "scrollIntoView({ block: 'nearest', inline: 'nearest' })"
+ )
})
it('rejects incomplete adversarial navigation evidence', async () => {
diff --git a/mobile/src/mobile-web/hosted-webview-simulator-e2e-options.test.ts b/mobile/src/mobile-web/hosted-webview-simulator-e2e-options.test.ts
index ea9e94b9bcc..12cf82dfe93 100644
--- a/mobile/src/mobile-web/hosted-webview-simulator-e2e-options.test.ts
+++ b/mobile/src/mobile-web/hosted-webview-simulator-e2e-options.test.ts
@@ -5,6 +5,7 @@ describe('hosted WebView simulator E2E options', () => {
it('retains bounded defaults', () => {
expect(parseHostedWebViewSimulatorE2eOptions([])).toEqual({
accountsOnly: false,
+ adversarialContent: false,
clipboardImageOnly: false,
device: 'iPhone 17 Pro',
expectedBuild: undefined,
@@ -80,6 +81,13 @@ describe('hosted WebView simulator E2E options', () => {
})
})
+ it('maps adversarial content onto the focused Source Control journey', () => {
+ expect(parseHostedWebViewSimulatorE2eOptions(['--adversarial-content'])).toMatchObject({
+ adversarialContent: true,
+ sourceControlOnly: true
+ })
+ })
+
it('rejects mutually exclusive focused journeys', () => {
expect(() =>
parseHostedWebViewSimulatorE2eOptions(['--accounts-only', '--source-control-only'])
diff --git a/mobile/src/mobile-web/hosted-webview-workspace-activation.test.ts b/mobile/src/mobile-web/hosted-webview-workspace-activation.test.ts
index 2df05df6e25..f6a07d7a371 100644
--- a/mobile/src/mobile-web/hosted-webview-workspace-activation.test.ts
+++ b/mobile/src/mobile-web/hosted-webview-workspace-activation.test.ts
@@ -11,11 +11,12 @@ describe('hosted WebView workspace activation', () => {
expect(activate).toHaveBeenCalledOnce()
expect(activate).toHaveBeenCalledWith(document, {
kind: 'label',
- value: 'Open mobile-rearch'
+ value: 'Open mobile-rearch',
+ reveal: true
})
})
- it('retains grouped-list text expansion for older row markup', async () => {
+ it('reveals a grouped text row when accessible row labels are absent', async () => {
const missing = new Error('Hosted WebView control was not found: mobile-rearch')
const activate = vi
.fn()
@@ -33,7 +34,8 @@ describe('hosted WebView workspace activation', () => {
kind: 'text',
value: 'mobile-rearch',
ignoreCase: true,
- occurrence: 1
+ occurrence: 1,
+ reveal: true
}
)
expect(activate).toHaveBeenNthCalledWith(
@@ -42,7 +44,8 @@ describe('hosted WebView workspace activation', () => {
{
kind: 'text',
value: 'mobile-rearch',
- ignoreCase: true
+ ignoreCase: true,
+ reveal: true
}
)
expect(activate).toHaveBeenNthCalledWith(
@@ -52,7 +55,8 @@ describe('hosted WebView workspace activation', () => {
kind: 'text',
value: 'mobile-rearch',
ignoreCase: true,
- occurrence: 1
+ occurrence: 1,
+ reveal: true
}
)
})
@@ -74,7 +78,8 @@ describe('hosted WebView workspace activation', () => {
expect(resolveDocument).toHaveBeenCalledOnce()
expect(activate).toHaveBeenLastCalledWith(replacement, {
kind: 'label',
- value: 'Open mobile-rearch'
+ value: 'Open mobile-rearch',
+ reveal: true
})
})
@@ -95,7 +100,8 @@ describe('hosted WebView workspace activation', () => {
expect(resolveDocument).toHaveBeenCalledOnce()
expect(activate).toHaveBeenLastCalledWith(replacement, {
kind: 'label',
- value: 'Open mobile-rearch'
+ value: 'Open mobile-rearch',
+ reveal: true
})
})
@@ -113,10 +119,10 @@ describe('hosted WebView workspace activation', () => {
await activateHostedWorkspaceRow({}, 'mobile-rearch', activate, 1_000)
expect(activate).toHaveBeenCalledTimes(6)
- expect(activate).toHaveBeenLastCalledWith({}, expect.objectContaining({ occurrence: 1 }))
+ expect(activate).toHaveBeenLastCalledWith({}, expect.objectContaining({ reveal: true }))
})
- it('accepts navigation after activating the only text row', async () => {
+ it('accepts activation of the only matching text row', async () => {
const missing = new Error('Hosted WebView control was not found: mobile-rearch')
const stale = new Error('WebKit CDP connection closed')
const activate = vi
@@ -130,4 +136,27 @@ describe('hosted WebView workspace activation', () => {
expect(activate).toHaveBeenCalledTimes(4)
})
+
+ it('accepts an SPA route transition after the activated row disappears', async () => {
+ const missing = new Error('Hosted WebView control was not found: mobile-rearch')
+ const activate = vi
+ .fn()
+ .mockRejectedValueOnce(missing)
+ .mockRejectedValueOnce(missing)
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(missing)
+ const resolveDocument = vi.fn().mockResolvedValue({
+ href: 'https://orca-mobile-web.invalid/h/host/session/workspace'
+ })
+
+ await activateHostedWorkspaceRow(
+ { href: 'https://orca-mobile-web.invalid/h/host' },
+ 'mobile-rearch',
+ activate,
+ 1_000,
+ resolveDocument
+ )
+
+ expect(resolveDocument).toHaveBeenCalledOnce()
+ })
})