diff --git a/src/platforms/android/__tests__/device-input-state.test.ts b/src/platforms/android/__tests__/device-input-state.test.ts index 233726c22..a85d4f1f5 100644 --- a/src/platforms/android/__tests__/device-input-state.test.ts +++ b/src/platforms/android/__tests__/device-input-state.test.ts @@ -7,6 +7,7 @@ import { dismissAndroidKeyboard, getAndroidKeyboardState, getAndroidKeyboardStatusWithAdb, + writeAndroidClipboardWithAdb, } from '../device-input-state.ts'; import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts'; import { assertRejectsAppError, withFakeAdb } from '../../../__tests__/test-utils/index.ts'; @@ -206,6 +207,30 @@ test('getAndroidKeyboardState treats stale input view as hidden when the IME win ); }); +test('writeAndroidClipboardWithAdb shell-quotes text containing metacharacters', async () => { + const calls: string[][] = []; + const adb: AndroidAdbExecutor = async (args) => { + calls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }; + + await writeAndroidClipboardWithAdb(adb, 'otp; echo pwned'); + + assert.deepEqual(calls, [['shell', 'cmd', 'clipboard', 'set', 'text', "'otp; echo pwned'"]]); +}); + +test('writeAndroidClipboardWithAdb leaves safe text unquoted', async () => { + const calls: string[][] = []; + const adb: AndroidAdbExecutor = async (args) => { + calls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }; + + await writeAndroidClipboardWithAdb(adb, 'android-otp'); + + assert.deepEqual(calls, [['shell', 'cmd', 'clipboard', 'set', 'text', 'android-otp']]); +}); + test('dismissAndroidKeyboard skips keyevent when keyboard is already hidden', async () => { await withFakeAdb( (args) => { diff --git a/src/platforms/android/__tests__/input-actions.test.ts b/src/platforms/android/__tests__/input-actions.test.ts index 5297dee5d..b75158f91 100644 --- a/src/platforms/android/__tests__/input-actions.test.ts +++ b/src/platforms/android/__tests__/input-actions.test.ts @@ -202,6 +202,31 @@ test('typeAndroid sends one character at a time when delay is requested', async ); }); +test('typeAndroid shell-quotes text containing shell metacharacters', async () => { + await withFakeAdb( + () => undefined, + async ({ calls, device }) => { + await typeAndroid(device, 'otp; echo pwned'); + // The chunk carrying `;` is single-quoted so the device shell cannot + // re-tokenize it into a second command. + assert.deepEqual(shellInputTextCalls(calls), [ + ['shell', 'input', 'text', "'otp;%sech'"], + ['shell', 'input', 'text', 'o%spwned'], + ]); + }, + ); +}); + +test('typeAndroid leaves safe text unquoted', async () => { + await withFakeAdb( + () => undefined, + async ({ calls, device }) => { + await typeAndroid(device, 'hello'); + assert.deepEqual(shellInputTextCalls(calls), [['shell', 'input', 'text', 'hello']]); + }, + ); +}); + test('fillAndroid uses chunk-safe shell input and retries when verification still fails', async () => { // First `input text` writes a wrong partial value, so attempt 1 fails // verification and production retries with the smaller chunk size. diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index 92b80fcb6..a8afc6046 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -7,6 +7,7 @@ import { sleep } from '../../utils/timeouts.ts'; import type { AppsFilter } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; +import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts'; import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts'; import { waitForAndroidBoot } from './emulator-lifecycle.ts'; import { runAndroidAdb } from './adb.ts'; @@ -306,13 +307,8 @@ export type OpenAndroidAppOptions = { // characters, so they round-trip untouched. URLs and launch arguments are // user-supplied and may contain JSON, spaces, `#`, or `&`; each is single-quoted // unless it consists entirely of safe shell characters. -function quoteAndroidShellArg(arg: string): string { - if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg; - return `'${arg.replace(/'/g, `'\\''`)}'`; -} - function androidLaunchArgs(options: OpenAndroidAppOptions): string[] { - return (options.launchArgs ?? []).map(quoteAndroidShellArg); + return (options.launchArgs ?? []).map(shellQuoteIfNeeded); } export async function openAndroidApp( @@ -367,7 +363,7 @@ async function openAndroidDeepLink( '-a', 'android.intent.action.VIEW', '-d', - quoteAndroidShellArg(target), + shellQuoteIfNeeded(target), ...androidDeepLinkPackageArgs(options.appBundleId), ...androidLaunchArgs(options), ]); @@ -398,7 +394,7 @@ async function openAndroidAppBoundDeepLink( '-a', 'android.intent.action.VIEW', '-d', - quoteAndroidShellArg(deepLinkUrl), + shellQuoteIfNeeded(deepLinkUrl), '-p', resolved, ...androidLaunchArgs(options), diff --git a/src/platforms/android/device-input-state.ts b/src/platforms/android/device-input-state.ts index 61d25630c..cec37ffd6 100644 --- a/src/platforms/android/device-input-state.ts +++ b/src/platforms/android/device-input-state.ts @@ -1,6 +1,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts'; import { isClipboardShellUnsupported, sleep } from './adb.ts'; import { androidAdbResultError, @@ -308,7 +309,7 @@ export async function writeAndroidClipboardWithAdb( ): Promise { await runAndroidClipboardShellCommand( adb, - ['shell', 'cmd', 'clipboard', 'set', 'text', text], + ['shell', 'cmd', 'clipboard', 'set', 'text', shellQuoteIfNeeded(text)], 'write', ); } diff --git a/src/platforms/android/input-actions.ts b/src/platforms/android/input-actions.ts index a80cab14a..31cbbc512 100644 --- a/src/platforms/android/input-actions.ts +++ b/src/platforms/android/input-actions.ts @@ -11,6 +11,7 @@ import { import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts'; import { resolveAndroidAdbExecutor, resolveAndroidTextInjector, @@ -392,7 +393,12 @@ async function typeAndroidShell( async function typeAndroidShellChunk(device: DeviceInfo, text: string): Promise { if (!text) return; try { - await runAndroidAdb(device, ['shell', 'input', 'text', encodeAndroidInputText(text)]); + await runAndroidAdb(device, [ + 'shell', + 'input', + 'text', + shellQuoteIfNeeded(encodeAndroidInputText(text)), + ]); } catch (error) { if (isAndroidInputTextUnsupported(error)) { throw unsupportedAndroidShellTextError(text, error); diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 6af601164..b7257a2b2 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -1482,7 +1482,7 @@ function assertAndroidPushAndEventContract(world: AndroidSettingsWorld): void { 'com.example.demo', ]); assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'get', 'text']); - assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'set', 'text', 'android otp']); + assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'set', 'text', "'android otp'"]); assertCommandCall(adbCalls, ['shell', 'dumpsys', 'input_method']); } diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index 0113dc254..e1a87b68d 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -263,16 +263,33 @@ function createAndroidProviderShellState(): AndroidProviderShellState { return { searchText: '', clipboardText: 'hello' }; } +const ANDROID_CLIPBOARD_SET_TEXT_PREFIX = ['shell', 'cmd', 'clipboard', 'set', 'text']; + function updateAndroidProviderShellState(args: string[], state: AndroidProviderShellState): void { if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'text') { state.searchText = String(args[3] ?? '').replaceAll('%s', ' '); return; } - if (args.join(' ') === 'shell cmd clipboard set text android otp') { - state.clipboardText = 'android otp'; + if (argsStartWith(args, ANDROID_CLIPBOARD_SET_TEXT_PREFIX)) { + state.clipboardText = unquoteAndroidShellArg( + String(args[ANDROID_CLIPBOARD_SET_TEXT_PREFIX.length] ?? ''), + ); } } +function argsStartWith(args: string[], prefix: string[]): boolean { + return prefix.every((value, index) => args[index] === value); +} + +// The real device shell unwraps a single-quoted argument (and collapses the +// `'\''` escape back to `'`) before `cmd` ever sees it, so this harness has +// to mirror that unwrap to keep modelling what the device actually receives +// — the inverse of the quoting in src/utils/shell-quote.ts. +function unquoteAndroidShellArg(value: string): string { + if (!value.startsWith("'") || !value.endsWith("'") || value.length < 2) return value; + return value.slice(1, -1).replaceAll("'\\''", "'"); +} + function androidDeviceStateAdbResult( key: string, args: string[],