diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index acf40bb0f2..0a9038e3df 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -340,7 +340,43 @@ { "id": "android-adb.input-tap.failed", "producer": "android-adb", - "trigger": "adb input tap started and failed", + "trigger": "adb input tap started and failed with an exit the host adb did not refuse", + "dispatched": "unknown" + }, + { + "id": "android-adb.input-tap.host-refused.device-unauthorized", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (device_unauthorized) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.host-refused.device-offline", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (device_offline) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.host-refused.multiple-devices", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (multiple_devices) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.host-refused.no-devices", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (no_devices) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.host-refused.device-not-found", + "producer": "android-adb", + "trigger": "adb input tap refused by the host adb (device_not_found) before it reached the device; the classifier's hostRefusal verdict, stamped as details.adbHostRefusal, makes it no", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.refusal-text-beside-other-output", + "producer": "android-adb", + "trigger": "adb input tap failed with a recognized refusal line followed by a non-trailer line (error: device offline, then Killed); only a refusal that is the entire output makes it no", "dispatched": "unknown" }, { @@ -391,6 +427,12 @@ "trigger": "one-shot helper gesture failed before returning parseable output", "dispatched": "unknown" }, + { + "id": "android-helper.gesture.host-refused", + "producer": "android-helper", + "trigger": "one-shot helper gesture: the host adb refused the am instrument launch (device unauthorized) before it reached the device", + "dispatched": "no" + }, { "id": "android-helper.ime.broadcast-tool-missing", "producer": "android-helper", diff --git a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts index 458fce8dfc..c2ac1bad5c 100644 --- a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts +++ b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts @@ -183,13 +183,36 @@ const HELPER_REPORTED_FAILURE = resultRecord({ }); const HELPER_RESULT = resultRecord({ ok: 'true', kind: 'swipe', injectedEvents: '4' }); +/** The host adb's stderr for each refusal the classifier marks `hostRefusal`, keyed by row suffix. */ +const HOST_REFUSAL_STDERR: Record = { + 'device-unauthorized': + "error: device unauthorized.\nThis adb server's $ADB_VENDOR_KEYS is not set", + 'device-offline': 'error: device offline', + 'multiple-devices': 'error: more than one device/emulator', + 'no-devices': 'adb: no devices/emulators found', + 'device-not-found': "error: device 'emulator-5554' not found", +}; + const DRIVERS: Record Promise; dispatchedSteps?: number }> = { 'android-adb.input-tap.tool-missing': { drive: () => tapWithAdbAnswer(new AppError('TOOL_MISSING', 'adb not found in PATH')), }, 'android-adb.input-tap.failed': { - drive: () => tapWithAdbAnswer({ exitCode: 1, stderr: 'error: device offline' }), + drive: () => tapWithAdbAnswer({ exitCode: 1, stderr: 'Killed' }), }, + 'android-adb.input-tap.refusal-text-beside-other-output': { + drive: () => + tapWithAdbAnswer({ + exitCode: 1, + stderr: 'error: device offline\nKilled', + }), + }, + ...Object.fromEntries( + Object.entries(HOST_REFUSAL_STDERR).map(([name, stderr]) => [ + `android-adb.input-tap.host-refused.${name}`, + { drive: () => tapWithAdbAnswer({ exitCode: 1, stderr }) }, + ]), + ), 'android-adb.input-tap.double-tap-second-refused': { drive: doubleTapWithSecondTapRefused, dispatchedSteps: 1, @@ -228,6 +251,14 @@ const DRIVERS: Record Promise; dispatchedSteps?: 'android-helper.gesture.failed-after-result': { drive: () => oneShotGesture(async () => ({ exitCode: 1, stdout: HELPER_RESULT, stderr: '' })), }, + 'android-helper.gesture.host-refused': { + drive: () => + oneShotGesture(async () => ({ + exitCode: 1, + stdout: '', + stderr: 'error: device unauthorized.', + })), + }, 'android-helper.gesture.no-parseable-output': { drive: () => oneShotGesture(async () => ({ exitCode: 1, stdout: '', stderr: 'boom' })), }, diff --git a/packages/platform-android/src/__tests__/touch-helper.test.ts b/packages/platform-android/src/__tests__/touch-helper.test.ts index 0315f93f30..4ea815ce13 100644 --- a/packages/platform-android/src/__tests__/touch-helper.test.ts +++ b/packages/platform-android/src/__tests__/touch-helper.test.ts @@ -376,6 +376,33 @@ test('unparseable output with a non-zero exit code reports a helper failure', as ); }); +test('a parsed helper result with a non-zero exit keeps the recognized adb failure details', async () => { + const device = makeIsolatedDevice(); + await assert.rejects( + withAndroidAdbProvider( + { + exec: currentVersionAdb(async () => ({ + exitCode: 1, + stdout: [resultRecord({ ok: 'true', kind: 'swipe' }), 'INSTRUMENTATION_CODE: 0'].join( + '\n', + ), + stderr: 'adb: device offline', + })), + }, + { serial: device.id }, + async () => await executeAndroidTouchHelperPlan(device, lowerAndroidTouchPlan(flingPlan())), + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'Android automation helper failed'); + assert.equal(error.details?.adbFailure, 'device_offline'); + assert.equal(typeof error.details?.hint, 'string'); + assert.equal(error.details?.retriable, true); + return true; + }, + ); +}); + test('one-shot viewport instruments the snapshot-helper runner and validates bounds', async () => { const device = makeIsolatedDevice(); let capturedArgs: readonly string[] | undefined; diff --git a/packages/platform-android/src/adb-failure.test.ts b/packages/platform-android/src/adb-failure.test.ts index 3e04f31270..3abfb62bb5 100644 --- a/packages/platform-android/src/adb-failure.test.ts +++ b/packages/platform-android/src/adb-failure.test.ts @@ -27,7 +27,7 @@ test('only the bare host adb device-offline refusal is classified as a host refu for (const stderr of ['adb: device offline\n', "error: device 'emulator-5554' offline"]) { assert.deepEqual(classifyAndroidAdbFailure(stderr), { reason: 'device_offline', - hint: classifyAndroidAdbFailure('device offline')?.hint, + hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.', retriable: true, hostRefusal: true, }); @@ -55,6 +55,69 @@ test('only the bare host adb device-offline refusal is classified as a host refu assert.equal(Object.hasOwn(timedOut.details ?? {}, 'adbHostRefusal'), false); }); +const HOST_REFUSALS = [ + ['device_unauthorized', 'error: device unauthorized.'], + ['device_offline', 'error: device offline'], + ['multiple_devices', 'error: more than one device/emulator'], + ['no_devices', 'adb: no devices/emulators found'], + ['device_not_found', "error: device 'emulator-5554' not found"], +] as const; + +const DEVICE_SIDE_FAILURES = [ + ['connection_dropped', 'adb: error: protocol fault', ''], + ['install_insufficient_storage', '', 'Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]'], + ['install_update_incompatible', '', 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]'], + ['install_version_downgrade', '', 'Failure [INSTALL_FAILED_VERSION_DOWNGRADE]'], + ['install_failed', '', 'Failure [INSTALL_FAILED_DEXOPT]'], +] as const; + +test('every host-refusal reason is classified hostRefusal and stamped as adbHostRefusal', () => { + for (const [reason, stderr] of HOST_REFUSALS) { + assert.equal(classifyAndroidAdbFailure(stderr)?.reason, reason); + assert.equal(classifyAndroidAdbFailure(stderr)?.hostRefusal, true, reason); + const error = attachAdbFailureHint( + new AppError('COMMAND_FAILED', 'adb exited with code 1', { stderr }), + ); + assert.equal(error.details?.adbFailure, reason); + assert.equal(error.details?.adbHostRefusal, true, reason); + } +}); + +test('refusal text never proves a refusal unless it is the entire adb output', () => { + for (const [reason, refusal] of HOST_REFUSALS) { + for (const [stderr, stdout] of [ + [refusal, 'partial'], + [`${refusal}\nKilled`, ''], + [`adb server version (40) doesn't match this client (41); killing...\n${refusal}`, ''], + [`${refusal}, giving up`, ''], + ] as const) { + const failure = classifyAndroidAdbFailure(stderr, stdout); + assert.equal(failure?.hostRefusal, undefined, `${reason}: ${stderr} | ${stdout}`); + const error = attachAdbFailureHint( + new AppError('COMMAND_FAILED', 'adb exited with code 1', { stderr, stdout }), + ); + assert.equal(Object.hasOwn(error.details ?? {}, 'adbHostRefusal'), false, reason); + } + } + const mismatch = classifyAndroidAdbFailure( + "adb server version (40) doesn't match this client (41); killing...", + ); + assert.equal(mismatch?.reason, 'server_version_mismatch'); + assert.equal(mismatch?.hostRefusal, undefined); +}); + +test('reasons a device-side command can produce are never classified hostRefusal', () => { + for (const [reason, stderr, stdout] of DEVICE_SIDE_FAILURES) { + const failure = classifyAndroidAdbFailure(stderr, stdout); + assert.equal(failure?.reason, reason); + assert.equal(failure?.hostRefusal, undefined, reason); + const error = attachAdbFailureHint( + new AppError('COMMAND_FAILED', 'adb exited with code 1', { stderr, stdout }), + ); + assert.equal(Object.hasOwn(error.details ?? {}, 'adbHostRefusal'), false, reason); + } +}); + test('ADB discovery classifies transport failures from stderr without trusting stdout', () => { const stdoutOnly = androidDiscoveryCommandError( 'adb devices failed', diff --git a/packages/platform-android/src/adb-failure.ts b/packages/platform-android/src/adb-failure.ts index 76839dade1..21bb53975e 100644 --- a/packages/platform-android/src/adb-failure.ts +++ b/packages/platform-android/src/adb-failure.ts @@ -23,38 +23,50 @@ export type AndroidAdbFailureClassification = Readonly<{ type AndroidAdbFailureMatcher = readonly [ pattern: RegExp, failure: AndroidAdbFailureClassification, - matchStdout?: true, + options?: Readonly<{ + matchStdout?: true; + /** Anchored shape of the host adb's first stderr line when it issues this refusal before the command leaves the host. */ + hostRefusal?: RegExp; + }>, ]; +const HOST_REFUSAL_PREFIX = String.raw`(?:adb|error): `; + +function hostRefusalHead(body: string): RegExp { + return new RegExp(`^${HOST_REFUSAL_PREFIX}${body}$`); +} + const ANDROID_ADB_DEVICE_OFFLINE_FAILURE = { reason: 'device_offline', hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.', retriable: true, } as const satisfies AndroidAdbFailureClassification; -/** The entire stderr, lowercased, of a command the host adb refused because the device was offline. */ -const ADB_DEVICE_OFFLINE_HOST_REFUSAL = /^(?:adb|error): device (?:'[^']*' )?offline$/; - -const ANDROID_ADB_DEVICE_OFFLINE_HOST_REFUSAL: AndroidAdbFailureClassification = Object.freeze({ - ...ANDROID_ADB_DEVICE_OFFLINE_FAILURE, - hostRefusal: true, -}); - -const ANDROID_ADB_FAILURE_MATCHERS = [ +const ANDROID_ADB_FAILURE_MATCHERS: readonly AndroidAdbFailureMatcher[] = [ [ /device unauthorized|device still authorizing/, { reason: 'device_unauthorized', hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.', }, + { hostRefusal: hostRefusalHead(String.raw`device (?:unauthorized|still authorizing)\.?`) }, + ], + [ + /device offline/, + ANDROID_ADB_DEVICE_OFFLINE_FAILURE, + { hostRefusal: hostRefusalHead(String.raw`device (?:'[^']*' )?offline`) }, ], - [/device offline/, ANDROID_ADB_DEVICE_OFFLINE_FAILURE], [ /more than one (?:device\/emulator|device and emulator)/, { reason: 'multiple_devices', hint: 'Multiple Android devices are connected — pass --serial (see adb devices) to select one.', }, + { + hostRefusal: hostRefusalHead( + String.raw`more than one (?:device/emulator|device and emulator)`, + ), + }, ], [ /no devices\/emulators found|no devices found/, @@ -62,6 +74,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'no_devices', hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.', }, + { hostRefusal: hostRefusalHead(String.raw`no devices(?:/emulators)? found`) }, ], [ /device (?:'[^']*' )?not found/, @@ -70,6 +83,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.', retriable: true, }, + { hostRefusal: hostRefusalHead(String.raw`device (?:'[^']*' )?not found`) }, ], [ /adb server version \(\d+\) doesn't match this client/, @@ -93,7 +107,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'install_insufficient_storage', hint: 'The device is out of storage — free up space or uninstall unused apps, then retry the install.', }, - true, + { matchStdout: true }, ], [ /install_failed_update_incompatible/, @@ -101,7 +115,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'install_update_incompatible', hint: 'The installed app has an incompatible signature — uninstall the existing app first, then retry the install.', }, - true, + { matchStdout: true }, ], [ /install_failed_version_downgrade/, @@ -109,7 +123,7 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'install_version_downgrade', hint: 'The APK is older than the installed app — uninstall the app first (or install with downgrade allowed), then retry.', }, - true, + { matchStdout: true }, ], [ /install_failed_\w+|install_parse_failed_\w+/, @@ -117,9 +131,9 @@ const ANDROID_ADB_FAILURE_MATCHERS = [ reason: 'install_failed', hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.', }, - true, + { matchStdout: true }, ], -] as const satisfies readonly AndroidAdbFailureMatcher[]; +]; const ANDROID_ADB_TIMEOUT_FAILURE: AndroidAdbFailureClassification = Object.freeze({ reason: 'timeout', @@ -129,17 +143,38 @@ const ANDROID_ADB_TIMEOUT_FAILURE: AndroidAdbFailureClassification = Object.free const ANDROID_HELPER_INSTALL_TIMEOUT_HINT = 'The helper install timed out — some OEM builds (ColorOS is one) hold the first install of each package behind a system install-confirmation dialog and adb waits for it. Check the device screen, confirm any pending install prompt, then retry; if none is showing, run adb kill-server && adb start-server and retry.'; +/** The fixed trailer lines adb appends to a device_unauthorized refusal. */ +const HOST_REFUSAL_TRAILER = + /^(?:this adb server's \$adb_vendor_keys is not set|try 'adb kill-server' if that seems wrong\.|(?:otherwise|please) check (?:for|the) (?:a )?confirmation dialog on your device\.)$/; + +function classifyHostRefusal( + stderrText: string, + stdoutText: string, +): AndroidAdbFailureClassification | undefined { + if (stdoutText !== '') return undefined; + const [head, ...trailer] = stderrText + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== ''); + if (head === undefined || !trailer.every((line) => HOST_REFUSAL_TRAILER.test(line))) { + return undefined; + } + const row = ANDROID_ADB_FAILURE_MATCHERS.find(([, , options]) => + options?.hostRefusal?.test(head), + ); + return row && { ...row[1], hostRefusal: true }; +} + export function classifyAndroidAdbFailure( stderr: string, stdout = '', ): AndroidAdbFailureClassification | undefined { const stderrText = stderr.toLowerCase(); const stdoutText = stdout.toLowerCase(); - if (stdoutText === '' && ADB_DEVICE_OFFLINE_HOST_REFUSAL.test(stderrText.trim())) { - return ANDROID_ADB_DEVICE_OFFLINE_HOST_REFUSAL; - } - for (const [pattern, classification, matchStdout] of ANDROID_ADB_FAILURE_MATCHERS) { - if (pattern.test(stderrText) || (matchStdout && pattern.test(stdoutText))) { + const refusal = classifyHostRefusal(stderrText, stdoutText); + if (refusal) return refusal; + for (const [pattern, classification, options] of ANDROID_ADB_FAILURE_MATCHERS) { + if (pattern.test(stderrText) || (options?.matchStdout && pattern.test(stdoutText))) { return classification; } } @@ -282,8 +317,16 @@ export function attachAndroidDiscoveryTimeout(error: T): T { return error; } -/** One adb input send's failure: adb that never started delivered nothing; otherwise it may have. */ +/** One adb input send's failure: adb that never started or a host adb refusal delivered nothing; otherwise it may have. */ export function discloseAdbInputDispatch(error: unknown): unknown { if (!(error instanceof AppError)) return error; - return discloseDispatch(error, error.code === 'TOOL_MISSING' ? 'no' : 'unknown'); + return discloseDispatch( + error, + error.code === 'TOOL_MISSING' || isAdbHostRefusal(error) ? 'no' : 'unknown', + ); +} + +/** Whether the host adb refused the command before it reached the device, so it had no effect there. */ +export function isAdbHostRefusal(error: AppError): boolean { + return error.details?.adbHostRefusal === true; } diff --git a/packages/platform-android/src/snapshot-helper-session.ts b/packages/platform-android/src/snapshot-helper-session.ts index 3a4c51433f..09943069b3 100644 --- a/packages/platform-android/src/snapshot-helper-session.ts +++ b/packages/platform-android/src/snapshot-helper-session.ts @@ -4,6 +4,7 @@ * `snapshot-helper-session-lifecycle.ts`, which this module acquires through. */ import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; +import { isAdbHostRefusal } from './adb-failure.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { readAndroidCaptureFailureReason } from '@agent-device/contracts/android-snapshot-quality'; import type { @@ -93,16 +94,16 @@ export type AndroidTouchHelperAction = 'gesture' | 'viewport'; /** * Only a gesture injects input, so only its failures say whether input reached the device. Its - * failure is `unknown` on every path: an `ok=false` result carries the thrown `errorType` but no - * count of events injected before the throw, so a parse refusal and a mid-injection failure read - * alike. + * failure is `unknown` unless the host adb refused the launch: an `ok=false` result carries the + * thrown `errorType` but no count of events injected before the throw, so a parse refusal and a + * mid-injection failure read alike. */ export function discloseHelperTouchDispatch( action: AndroidTouchHelperAction, error: unknown, ): unknown { if (action !== 'gesture' || !(error instanceof AppError)) return error; - return discloseDispatch(error, 'unknown'); + return discloseDispatch(error, isAdbHostRefusal(error) ? 'no' : 'unknown'); } export async function runAndroidSnapshotHelperSessionTouchCommand(params: { diff --git a/packages/platform-android/src/touch-helper.ts b/packages/platform-android/src/touch-helper.ts index 5be52a45ad..4a62c3b195 100644 --- a/packages/platform-android/src/touch-helper.ts +++ b/packages/platform-android/src/touch-helper.ts @@ -2,6 +2,7 @@ import type { PointerTrajectory } from '@agent-device/contracts/gesture-plan-typ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Rect } from '@agent-device/kernel/snapshot'; import { AppError } from '@agent-device/kernel/errors'; +import { attachAdbFailureHint } from './adb-failure.ts'; import { execFailureDetails } from '@agent-device/host-kit/command'; import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; @@ -294,25 +295,28 @@ async function runOneShotTouchHelper(options: { } if (error.code !== HELPER_NO_FINAL_RESULT) throw error; } + const unparsed = new AppError( + 'COMMAND_FAILED', + result.exitCode === 0 + ? 'Android automation helper output could not be parsed' + : 'Android automation helper failed before returning parseable output', + execFailureDetails(result), + error, + ); throw discloseHelperTouchDispatch( options.action, - new AppError( - 'COMMAND_FAILED', - result.exitCode === 0 - ? 'Android automation helper output could not be parsed' - : 'Android automation helper failed before returning parseable output', - execFailureDetails(result), - error, - ), + result.exitCode === 0 ? unparsed : attachAdbFailureHint(unparsed), ); } if (result.exitCode !== 0) { throw discloseHelperTouchDispatch( options.action, - new AppError( - 'COMMAND_FAILED', - 'Android automation helper failed', - execFailureDetails(result, { helper: finalRecord }), + attachAdbFailureHint( + new AppError( + 'COMMAND_FAILED', + 'Android automation helper failed', + execFailureDetails(result, { helper: finalRecord }), + ), ), ); }