diff --git a/src/platforms/android/__tests__/device-input-state.test.ts b/src/platforms/android/__tests__/device-input-state.test.ts index 01493fe4e..484cba636 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 { withScriptedAdb } from '../../../__tests__/test-utils/mocked-binaries.ts'; @@ -269,6 +270,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 withScriptedAdb( 'agent-device-android-keyboard-dismiss-hidden-', diff --git a/src/platforms/android/__tests__/input-actions.test.ts b/src/platforms/android/__tests__/input-actions.test.ts index 083d8fd26..14f7e75fc 100644 --- a/src/platforms/android/__tests__/input-actions.test.ts +++ b/src/platforms/android/__tests__/input-actions.test.ts @@ -208,6 +208,47 @@ test('typeAndroid sends one character at a time when delay is requested', async ); }); +test('typeAndroid shell-quotes text containing shell metacharacters', async () => { + await withScriptedAdb( + 'agent-device-android-type-shell-metacharacters-', + [ + '#!/bin/sh', + 'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"', + 'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"', + 'exit 0', + '', + ].join('\n'), + async ({ argsLogPath, device }) => { + await typeAndroid(device, 'otp; echo pwned'); + const logged = await fs.readFile(argsLogPath, 'utf8'); + // The chunk carrying `;` is single-quoted so the device shell cannot + // re-tokenize it into a second command. + assert.match(logged, /shell\ninput\ntext\n'otp;%sech'/); + // The next chunk has no shell-significant characters and stays unquoted. + assert.match(logged, /shell\ninput\ntext\no%spwned\n/); + }, + ); +}); + +test('typeAndroid leaves safe text unquoted', async () => { + await withScriptedAdb( + 'agent-device-android-type-safe-unquoted-', + [ + '#!/bin/sh', + 'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"', + 'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"', + 'exit 0', + '', + ].join('\n'), + async ({ argsLogPath, device }) => { + await typeAndroid(device, 'hello'); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /shell\ninput\ntext\nhello\n/); + assert.doesNotMatch(logged, /shell\ninput\ntext\n'/); + }, + ); +}); + test('fillAndroid uses chunk-safe shell input and retries when verification still fails', async () => { await withScriptedAdb( 'agent-device-android-fill-fallback-', diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index 1dc4b17ef..97a78a842 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 './devices.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 067ab48a2..1478ad78b 100644 --- a/src/platforms/android/input-actions.ts +++ b/src/platforms/android/input-actions.ts @@ -10,6 +10,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, @@ -398,7 +399,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[],