Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/platforms/android/__tests__/device-input-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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-',
Expand Down
41 changes: 41 additions & 0 deletions src/platforms/android/__tests__/input-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-',
Expand Down
12 changes: 4 additions & 8 deletions src/platforms/android/app-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -367,7 +363,7 @@ async function openAndroidDeepLink(
'-a',
'android.intent.action.VIEW',
'-d',
quoteAndroidShellArg(target),
shellQuoteIfNeeded(target),
...androidDeepLinkPackageArgs(options.appBundleId),
...androidLaunchArgs(options),
]);
Expand Down Expand Up @@ -398,7 +394,7 @@ async function openAndroidAppBoundDeepLink(
'-a',
'android.intent.action.VIEW',
'-d',
quoteAndroidShellArg(deepLinkUrl),
shellQuoteIfNeeded(deepLinkUrl),
'-p',
resolved,
...androidLaunchArgs(options),
Expand Down
3 changes: 2 additions & 1 deletion src/platforms/android/device-input-state.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -308,7 +309,7 @@ export async function writeAndroidClipboardWithAdb(
): Promise<void> {
await runAndroidClipboardShellCommand(
adb,
['shell', 'cmd', 'clipboard', 'set', 'text', text],
['shell', 'cmd', 'clipboard', 'set', 'text', shellQuoteIfNeeded(text)],
'write',
);
}
Expand Down
8 changes: 7 additions & 1 deletion src/platforms/android/input-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -398,7 +399,12 @@ async function typeAndroidShell(
async function typeAndroidShellChunk(device: DeviceInfo, text: string): Promise<void> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}

Expand Down
21 changes: 19 additions & 2 deletions test/integration/provider-scenarios/android-world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
Loading