diff --git a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts new file mode 100644 index 000000000..84f3c7d7d --- /dev/null +++ b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, expect, test } from 'vitest'; +import { createRequestHandler } from '../request-router.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { SessionStore } from '../session-store.ts'; +import { AppError } from '../../kernel/errors.ts'; +import { resetAndroidSnapshotHelperInstallCache } from '../../platforms/android/snapshot-helper-install.ts'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../__tests__/test-utils/index.ts'; +import type { AndroidAdbProvider } from '../../platforms/android/adb-executor.ts'; + +function makeAndroidSessionStore(name: string): SessionStore { + const sessionStore = new SessionStore(`/tmp/${name}`); + sessionStore.set('default', { + name: 'default', + createdAt: Date.now(), + device: { + platform: 'android', + id: 'remote-android-1', + name: 'Remote Android', + kind: 'device', + booted: true, + }, + appBundleId: 'com.example.app', + actions: [], + }); + return sessionStore; +} + +function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => AndroidAdbProvider) { + return createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 'token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + androidAdbProvider, + trackDownloadableArtifact: () => 'artifact-id', + }); +} + +beforeEach(() => { + resetAndroidSnapshotHelperInstallCache(); +}); + +// Regression for #1284/#1285 P1: a provider whose `install` REJECTS with an +// enriched INSTALL_FAILED_* error (instead of resolving with a nonzero result) +// must still surface the device-side install classification on the public +// daemon snapshot error, not the generic retry/doctor hint. +test('snapshot reports a device-side install failure when the provider install rejects', async () => { + const sessionStore = makeAndroidSessionStore( + 'agent-device-request-router-snapshot-helper-install-reject-test', + ); + const provider: AndroidAdbProvider = { + // Version probe reports no installed helper; every other device query is inert. + exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + install: async () => { + throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { + stderr: 'adb: failed to install helper.apk: Failure [INSTALL_FAILED_TEST_ONLY]', + stdout: '', + exitCode: 1, + processExitError: true, + }); + }, + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + }; + const handler = makeHandler(sessionStore, () => provider); + + const response = await handler({ + token: 'token', + session: 'default', + command: 'snapshot', + positionals: [], + flags: {}, + }); + + expect(response.ok).toBe(false); + if (response.ok) throw new Error('Expected snapshot to fail'); + expect(response.error.message).toMatch(/Android snapshot helper failed/); + expect(response.error.message).toMatch(/INSTALL_FAILED_TEST_ONLY/); + // The provider funnel's classified adb hint is preserved, with the + // device-side framing appended rather than replacing it. + expect(response.error.hint).toMatch(/package installer rejected the APK/); + expect(response.error.hint).toMatch(/device-side install failure/); + expect(response.error.hint).not.toMatch(/pnpm build:android/); + expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true); +}); + +// ADR 0010 wire contract: a transient-classified install rejection (here the +// funnel's `connection_dropped` family) must keep its structured `retriable` +// signal through the capture rewrap onto the public daemon error. +test('snapshot keeps the transient retry signal when the provider install rejection is retriable', async () => { + const sessionStore = makeAndroidSessionStore( + 'agent-device-request-router-snapshot-helper-install-transient-test', + ); + const provider: AndroidAdbProvider = { + exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + install: async () => { + throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { + stderr: 'adb: transport error while pushing helper.apk', + stdout: '', + exitCode: 1, + processExitError: true, + }); + }, + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + }; + const handler = makeHandler(sessionStore, () => provider); + + const response = await handler({ + token: 'token', + session: 'default', + command: 'snapshot', + positionals: [], + flags: {}, + }); + + expect(response.ok).toBe(false); + if (response.ok) throw new Error('Expected snapshot to fail'); + expect(response.error.message).toMatch(/Android snapshot helper failed/); + expect(response.error.hint).toMatch(/connection dropped/); + expect(response.error.hint).toMatch(/device-side install failure/); + expect(response.error.retriable).toBe(true); + expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true); +}); diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts index df2b1b1fc..a3f28fade 100644 --- a/src/platforms/android/__tests__/snapshot-helper.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper.test.ts @@ -13,6 +13,7 @@ import { forgetAndroidSnapshotHelperInstall, resetAndroidSnapshotHelperInstallCache, } from '../snapshot-helper-install.ts'; +import { AppError } from '../../../kernel/errors.ts'; import { parseAndroidSnapshotHelperManifest } from '../snapshot-helper-artifact.ts'; import { verifyAndroidHelperApkChecksum } from '../helper-package-install.ts'; import type { @@ -204,6 +205,80 @@ test('ensureAndroidSnapshotHelper installs when missing and skips a newer versio assert.equal(skipped.reason, 'current'); }); +test('ensureAndroidSnapshotHelper tags a device-side install rejection distinctly from artifact resolution', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-reject-')); + const apkPath = path.join(tmpDir, 'helper.apk'); + await fs.writeFile(apkPath, 'helper-apk'); + const adb: AndroidAdbExecutor = async (args) => { + if (args.includes('--show-versioncode')) { + return { exitCode: 1, stdout: '', stderr: 'not found' }; + } + if (args[0] === 'install') { + return { exitCode: 1, stdout: '', stderr: 'Failure [INSTALL_FAILED_TEST_ONLY]' }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + + await assert.rejects( + () => + ensureAndroidSnapshotHelper({ + adb, + artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } }, + }), + (error) => { + assert.match((error as Error).message, /Failed to install Android snapshot helper/); + assert.equal( + (error as { details?: Record }).details + ?.androidSnapshotHelperInstallFailure, + true, + ); + return true; + }, + ); +}); + +test('ensureAndroidSnapshotHelper tags a rejected provider install without losing the enriched error', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-throw-')); + const apkPath = path.join(tmpDir, 'helper.apk'); + await fs.writeFile(apkPath, 'helper-apk'); + const installError = new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { + stderr: 'adb: failed to install helper.apk: Failure [INSTALL_FAILED_TEST_ONLY]', + stdout: '', + exitCode: 1, + processExitError: true, + hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.', + }); + const adb: AndroidAdbExecutor = async (args) => { + if (args.includes('--show-versioncode')) { + return { exitCode: 1, stdout: '', stderr: 'not found' }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + const adbProvider: AndroidAdbProvider = { + exec: adb, + install: async () => { + throw installError; + }, + }; + + await assert.rejects( + () => + ensureAndroidSnapshotHelper({ + adb, + adbProvider, + artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } }, + }), + (error) => { + assert.equal(error, installError); + const details = (error as AppError).details; + assert.equal(details?.androidSnapshotHelperInstallFailure, true); + assert.match(String(details?.stderr), /INSTALL_FAILED_TEST_ONLY/); + assert.match(String(details?.hint), /package installer rejected the APK/); + return true; + }, + ); +}); + test('ensureAndroidSnapshotHelper replaces same-version helper when APK bytes differ', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-identity-')); const apkPath = path.join(tmpDir, 'helper.apk'); diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index 3e6752a15..8b203b250 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -892,6 +892,79 @@ test('snapshotAndroid emits unavailable diagnostics when helper artifact is miss } }); +test('snapshotAndroid gives an actionable hint when the helper artifact is missing on disk', async () => { + const accessSpy = vi.spyOn(fs, 'access').mockRejectedValueOnce(new Error('helper missing')); + + try { + await assert.rejects( + () => snapshotAndroid(device), + (error) => { + assert.match((error as Error).message, /the bundled helper artifact was not found/); + const hint = String((error as { details?: Record }).details?.hint); + assert.match(hint, /pnpm build:android/); + assert.match(hint, /prepack/); + assert.match(hint, /\.manifest\.json/); + assert.match(hint, /\.apk/); + // The npm package excludes *.idsig by design — the hint must never claim it is required. + assert.doesNotMatch(hint, /idsig/); + return true; + }, + ); + } finally { + accessSpy.mockRestore(); + } +}); + +test('snapshotAndroid distinguishes a device-side install rejection from a missing build artifact', async () => { + const helperAdb: AndroidAdbExecutor = async (args) => { + if (args.includes('--show-versioncode')) { + // No installed package reported, so ensureAndroidSnapshotHelper treats the + // helper as missing and attempts a real install. + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'install') { + return { exitCode: 1, stdout: '', stderr: 'Failure [INSTALL_FAILED_TEST_ONLY]' }; + } + throw new Error(`unexpected adb args: ${args.join(' ')}`); + }; + + await assert.rejects( + () => snapshotAndroidWithHelper(helperAdb), + (error) => { + const message = (error as Error).message; + assert.match(message, /Android snapshot helper failed/); + assert.match(message, /Failed to install Android snapshot helper/); + assert.match(message, /INSTALL_FAILED_TEST_ONLY/); + const hint = String((error as { details?: Record }).details?.hint); + assert.match(hint, /device-side install failure/); + assert.doesNotMatch(hint, /pnpm build:android/); + return true; + }, + ); +}); + +test('snapshotAndroid preserves upstream diagnosticId and logPath through the capture rewrap', async () => { + const helperAdb = createHelperAdb({ + instrument: async () => { + throw new AppError('COMMAND_FAILED', 'helper capture exploded', { + diagnosticId: 'diag-upstream', + logPath: '/tmp/upstream.ndjson', + }); + }, + }); + + await assert.rejects( + () => snapshotAndroidWithHelper(helperAdb), + (error) => { + assert.match((error as Error).message, /Android snapshot helper failed/); + const details = (error as { details?: Record }).details; + assert.equal(details?.diagnosticId, 'diag-upstream'); + assert.equal(details?.logPath, '/tmp/upstream.ndjson'); + return true; + }, + ); +}); + test('snapshotAndroid emits timeout diagnostics when helper capture times out', async () => { const helperAdb = createHelperAdb({ instrument: async () => { diff --git a/src/platforms/android/snapshot-helper-install.ts b/src/platforms/android/snapshot-helper-install.ts index f6a171cf5..ada996675 100644 --- a/src/platforms/android/snapshot-helper-install.ts +++ b/src/platforms/android/snapshot-helper-install.ts @@ -1,3 +1,4 @@ +import { asAppError, type AppError } from '../../kernel/errors.ts'; import { readAndroidSnapshotHelperInstallOptions } from './snapshot-helper-artifact.ts'; import { inspectInstalledAndroidHelper, @@ -136,22 +137,30 @@ export async function ensureAndroidSnapshotHelper(options: { }; } - const result = await installAndroidSnapshotHelper( - adb, - options.adbProvider ?? adb, - artifact.apkPath, - readAndroidSnapshotHelperInstallOptions(artifact.manifest), - { - packageName, - timeoutMs: options.timeoutMs, - }, - ); + let result: Awaited>; + try { + result = await installAndroidSnapshotHelper( + adb, + options.adbProvider ?? adb, + artifact.apkPath, + readAndroidSnapshotHelperInstallOptions(artifact.manifest), + { + packageName, + timeoutMs: options.timeoutMs, + }, + ); + } catch (error) { + forgetInstalledSnapshotHelper(installCacheKey); + throw markAndroidSnapshotHelperInstallFailure(error, { packageName, versionCode }); + } if (result.exitCode !== 0) { forgetInstalledSnapshotHelper(installCacheKey); - throw androidAdbResultError('Failed to install Android snapshot helper', result, { - packageName, - versionCode, - }); + throw markAndroidSnapshotHelperInstallFailure( + androidAdbResultError('Failed to install Android snapshot helper', result, { + packageName, + versionCode, + }), + ); } rememberInstalledSnapshotHelper(installCacheKey, versionCode); @@ -165,6 +174,23 @@ export async function ensureAndroidSnapshotHelper(options: { }; } +// Tags every failure of the helper install phase — nonzero results and provider +// rejections alike — so the capture layer can distinguish a device-side install +// rejection (adb/OEM policy) from a missing build artifact. Mutates details in +// place, preserving the original code, message, hint, and cause. +function markAndroidSnapshotHelperInstallFailure( + error: unknown, + context?: { packageName: string; versionCode: number }, +): AppError { + const appError = asAppError(error, 'COMMAND_FAILED'); + appError.details = { + ...context, + ...appError.details, + androidSnapshotHelperInstallFailure: true, + }; + return appError; +} + function normalizeAdbProvider( provider: AndroidAdbProvider | AndroidAdbExecutor | undefined, adb: AndroidAdbExecutor, diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 1f25979f3..8377e8414 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -1,6 +1,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { AppError, normalizeError, toAppErrorCode } from '../../kernel/errors.ts'; +import { + AppError, + normalizeError, + toAppErrorCode, + type NormalizedError, +} from '../../kernel/errors.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import { findProjectRoot, readVersion } from '../../utils/version.ts'; @@ -464,25 +469,53 @@ function formatAndroidSnapshotHelperFailureReason(error: unknown): string { function androidSnapshotHelperCaptureError(error: unknown, reason: string): AppError { const normalized = normalizeError(error); - const busy = - isStructuredHelperTimeout(normalized.details?.helper, normalized.message) || - isKilledHelperInstrumentationFailure(normalized); - const hint = busy - ? 'Android accessibility snapshots can be blocked by busy or continuously changing app UI. Use screenshot as visual truth after this timeout and report the busy UI if it persists.' - : (normalized.hint ?? - 'Retry once. If the helper still fails, run agent-device doctor and report the diagnostic log; agent-device does not substitute a second snapshot engine.'); return new AppError( toAppErrorCode(normalized.code), `Android snapshot helper failed: ${reason}`, { ...normalized.details, + ...liftedWireFields(normalized), androidSnapshotHelperFailureReason: reason, - hint, + hint: androidSnapshotHelperCaptureHint(normalized), }, error, ); } +function androidSnapshotHelperCaptureHint(normalized: NormalizedError): string { + const busy = + isStructuredHelperTimeout(normalized.details?.helper, normalized.message) || + isKilledHelperInstrumentationFailure(normalized); + if (busy) { + return 'Android accessibility snapshots can be blocked by busy or continuously changing app UI. Use screenshot as visual truth after this timeout and report the busy UI if it persists.'; + } + if (normalized.details?.androidSnapshotHelperInstallFailure === true) { + return [ + normalized.hint, + 'This is a device-side install failure (adb rejection or OEM policy) — the helper artifact itself is present, this is not a missing/unbuilt build.', + ] + .filter(Boolean) + .join(' '); + } + return ( + normalized.hint ?? + 'Retry once. If the helper still fails, run agent-device doctor and report the diagnostic log; agent-device does not substitute a second snapshot engine.' + ); +} + +// normalizeError hoists these wire-contract fields out of details (ADR 0010; +// the complete set stripDiagnosticMeta removes, minus hint, which +// androidSnapshotHelperCaptureHint owns). A rewrap must put every one back so +// upstream diagnostics and typed signals like `retriable` survive. +function liftedWireFields(normalized: NormalizedError): Record { + const fields: Record = {}; + if (normalized.diagnosticId !== undefined) fields.diagnosticId = normalized.diagnosticId; + if (normalized.logPath !== undefined) fields.logPath = normalized.logPath; + if (normalized.retriable !== undefined) fields.retriable = normalized.retriable; + if (normalized.supportedOn !== undefined) fields.supportedOn = normalized.supportedOn; + return fields; +} + function isKilledHelperInstrumentationFailure(error: { message: string; details?: Record; @@ -545,7 +578,7 @@ function androidSnapshotHelperUnavailableError(errorReason: string | undefined): const reason = errorReason ?? 'the bundled helper artifact was not found'; return new AppError('COMMAND_FAILED', `Android snapshot helper is unavailable: ${reason}`, { androidSnapshotHelperFailureReason: reason, - hint: 'For a source checkout, run pnpm build:android. For a packaged install, reinstall agent-device; the Android snapshot helper must ship with the package.', + hint: 'Run `pnpm build:android` to build the helper dist for a source checkout — the runtime needs android/snapshot-helper/dist/agent-device-android-snapshot-helper-.manifest.json and the .apk it references. Packaged installs ship these via the prepack script; if they are missing from a packaged install, reinstall agent-device.', }); }