Skip to content

Commit 8d1440f

Browse files
committed
fix(android): shell-quote free-text arguments reaching the device shell
Text entry (input text) and clipboard write (cmd clipboard set text) now quote their free-form text argument with the same shellQuoteIfNeeded helper app-lifecycle.ts already uses for deep-link URLs and launch arguments, and app-lifecycle.ts's local duplicate of that helper is retired in favor of the shared one. Multi-word clipboard writes also now arrive at the device as a single argument instead of being re-tokenized into separate ones. Updates the provider-scenario test harness's scripted clipboard-state simulator to unwrap shell quoting the same way a device shell does, so it keeps modelling what the device actually receives.
1 parent d5f11f6 commit 8d1440f

7 files changed

Lines changed: 99 additions & 13 deletions

File tree

‎src/platforms/android/__tests__/device-input-state.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
dismissAndroidKeyboard,
88
getAndroidKeyboardState,
99
getAndroidKeyboardStatusWithAdb,
10+
writeAndroidClipboardWithAdb,
1011
} from '../device-input-state.ts';
1112
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts';
1213
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
269270
);
270271
});
271272

273+
test('writeAndroidClipboardWithAdb shell-quotes text containing metacharacters', async () => {
274+
const calls: string[][] = [];
275+
const adb: AndroidAdbExecutor = async (args) => {
276+
calls.push(args);
277+
return { stdout: '', stderr: '', exitCode: 0 };
278+
};
279+
280+
await writeAndroidClipboardWithAdb(adb, 'otp; echo pwned');
281+
282+
assert.deepEqual(calls, [['shell', 'cmd', 'clipboard', 'set', 'text', "'otp; echo pwned'"]]);
283+
});
284+
285+
test('writeAndroidClipboardWithAdb leaves safe text unquoted', async () => {
286+
const calls: string[][] = [];
287+
const adb: AndroidAdbExecutor = async (args) => {
288+
calls.push(args);
289+
return { stdout: '', stderr: '', exitCode: 0 };
290+
};
291+
292+
await writeAndroidClipboardWithAdb(adb, 'android-otp');
293+
294+
assert.deepEqual(calls, [['shell', 'cmd', 'clipboard', 'set', 'text', 'android-otp']]);
295+
});
296+
272297
test('dismissAndroidKeyboard skips keyevent when keyboard is already hidden', async () => {
273298
await withScriptedAdb(
274299
'agent-device-android-keyboard-dismiss-hidden-',

‎src/platforms/android/__tests__/input-actions.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,47 @@ test('typeAndroid sends one character at a time when delay is requested', async
208208
);
209209
});
210210

211+
test('typeAndroid shell-quotes text containing shell metacharacters', async () => {
212+
await withScriptedAdb(
213+
'agent-device-android-type-shell-metacharacters-',
214+
[
215+
'#!/bin/sh',
216+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
217+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
218+
'exit 0',
219+
'',
220+
].join('\n'),
221+
async ({ argsLogPath, device }) => {
222+
await typeAndroid(device, 'otp; echo pwned');
223+
const logged = await fs.readFile(argsLogPath, 'utf8');
224+
// The chunk carrying `;` is single-quoted so the device shell cannot
225+
// re-tokenize it into a second command.
226+
assert.match(logged, /shell\ninput\ntext\n'otp;%sech'/);
227+
// The next chunk has no shell-significant characters and stays unquoted.
228+
assert.match(logged, /shell\ninput\ntext\no%spwned\n/);
229+
},
230+
);
231+
});
232+
233+
test('typeAndroid leaves safe text unquoted', async () => {
234+
await withScriptedAdb(
235+
'agent-device-android-type-safe-unquoted-',
236+
[
237+
'#!/bin/sh',
238+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
239+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
240+
'exit 0',
241+
'',
242+
].join('\n'),
243+
async ({ argsLogPath, device }) => {
244+
await typeAndroid(device, 'hello');
245+
const logged = await fs.readFile(argsLogPath, 'utf8');
246+
assert.match(logged, /shell\ninput\ntext\nhello\n/);
247+
assert.doesNotMatch(logged, /shell\ninput\ntext\n'/);
248+
},
249+
);
250+
});
251+
211252
test('fillAndroid uses chunk-safe shell input and retries when verification still fails', async () => {
212253
await withScriptedAdb(
213254
'agent-device-android-fill-fallback-',

‎src/platforms/android/app-lifecycle.ts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { sleep } from '../../utils/timeouts.ts';
77
import type { AppsFilter } from '@agent-device/contracts/device';
88
import type { DeviceInfo } from '@agent-device/kernel/device';
99
import { isDeepLinkTarget } from '@agent-device/contracts/command';
10+
import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts';
1011
import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts';
1112
import { waitForAndroidBoot } from './devices.ts';
1213
import { runAndroidAdb } from './adb.ts';
@@ -306,13 +307,8 @@ export type OpenAndroidAppOptions = {
306307
// characters, so they round-trip untouched. URLs and launch arguments are
307308
// user-supplied and may contain JSON, spaces, `#`, or `&`; each is single-quoted
308309
// unless it consists entirely of safe shell characters.
309-
function quoteAndroidShellArg(arg: string): string {
310-
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg;
311-
return `'${arg.replace(/'/g, `'\\''`)}'`;
312-
}
313-
314310
function androidLaunchArgs(options: OpenAndroidAppOptions): string[] {
315-
return (options.launchArgs ?? []).map(quoteAndroidShellArg);
311+
return (options.launchArgs ?? []).map(shellQuoteIfNeeded);
316312
}
317313

318314
export async function openAndroidApp(
@@ -367,7 +363,7 @@ async function openAndroidDeepLink(
367363
'-a',
368364
'android.intent.action.VIEW',
369365
'-d',
370-
quoteAndroidShellArg(target),
366+
shellQuoteIfNeeded(target),
371367
...androidDeepLinkPackageArgs(options.appBundleId),
372368
...androidLaunchArgs(options),
373369
]);
@@ -398,7 +394,7 @@ async function openAndroidAppBoundDeepLink(
398394
'-a',
399395
'android.intent.action.VIEW',
400396
'-d',
401-
quoteAndroidShellArg(deepLinkUrl),
397+
shellQuoteIfNeeded(deepLinkUrl),
402398
'-p',
403399
resolved,
404400
...androidLaunchArgs(options),

‎src/platforms/android/device-input-state.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { emitDiagnostic } from '../../utils/diagnostics.ts';
22
import type { DeviceInfo } from '@agent-device/kernel/device';
33
import { AppError } from '@agent-device/kernel/errors';
4+
import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts';
45
import { isClipboardShellUnsupported, sleep } from './adb.ts';
56
import {
67
androidAdbResultError,
@@ -308,7 +309,7 @@ export async function writeAndroidClipboardWithAdb(
308309
): Promise<void> {
309310
await runAndroidClipboardShellCommand(
310311
adb,
311-
['shell', 'cmd', 'clipboard', 'set', 'text', text],
312+
['shell', 'cmd', 'clipboard', 'set', 'text', shellQuoteIfNeeded(text)],
312313
'write',
313314
);
314315
}

‎src/platforms/android/input-actions.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import type { DeviceInfo } from '@agent-device/kernel/device';
1111
import { AppError } from '@agent-device/kernel/errors';
1212
import { emitDiagnostic } from '../../utils/diagnostics.ts';
13+
import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts';
1314
import {
1415
resolveAndroidAdbExecutor,
1516
resolveAndroidTextInjector,
@@ -398,7 +399,12 @@ async function typeAndroidShell(
398399
async function typeAndroidShellChunk(device: DeviceInfo, text: string): Promise<void> {
399400
if (!text) return;
400401
try {
401-
await runAndroidAdb(device, ['shell', 'input', 'text', encodeAndroidInputText(text)]);
402+
await runAndroidAdb(device, [
403+
'shell',
404+
'input',
405+
'text',
406+
shellQuoteIfNeeded(encodeAndroidInputText(text)),
407+
]);
402408
} catch (error) {
403409
if (isAndroidInputTextUnsupported(error)) {
404410
throw unsupportedAndroidShellTextError(text, error);

‎test/integration/provider-scenarios/android-lifecycle.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1482,7 +1482,7 @@ function assertAndroidPushAndEventContract(world: AndroidSettingsWorld): void {
14821482
'com.example.demo',
14831483
]);
14841484
assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'get', 'text']);
1485-
assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'set', 'text', 'android otp']);
1485+
assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'set', 'text', "'android otp'"]);
14861486
assertCommandCall(adbCalls, ['shell', 'dumpsys', 'input_method']);
14871487
}
14881488

‎test/integration/provider-scenarios/android-world.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,33 @@ function createAndroidProviderShellState(): AndroidProviderShellState {
263263
return { searchText: '', clipboardText: 'hello' };
264264
}
265265

266+
const ANDROID_CLIPBOARD_SET_TEXT_PREFIX = ['shell', 'cmd', 'clipboard', 'set', 'text'];
267+
266268
function updateAndroidProviderShellState(args: string[], state: AndroidProviderShellState): void {
267269
if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'text') {
268270
state.searchText = String(args[3] ?? '').replaceAll('%s', ' ');
269271
return;
270272
}
271-
if (args.join(' ') === 'shell cmd clipboard set text android otp') {
272-
state.clipboardText = 'android otp';
273+
if (argsStartWith(args, ANDROID_CLIPBOARD_SET_TEXT_PREFIX)) {
274+
state.clipboardText = unquoteAndroidShellArg(
275+
String(args[ANDROID_CLIPBOARD_SET_TEXT_PREFIX.length] ?? ''),
276+
);
273277
}
274278
}
275279

280+
function argsStartWith(args: string[], prefix: string[]): boolean {
281+
return prefix.every((value, index) => args[index] === value);
282+
}
283+
284+
// The real device shell unwraps a single-quoted argument (and collapses the
285+
// `'\''` escape back to `'`) before `cmd` ever sees it, so this harness has
286+
// to mirror that unwrap to keep modelling what the device actually receives
287+
// — the inverse of the quoting in src/utils/shell-quote.ts.
288+
function unquoteAndroidShellArg(value: string): string {
289+
if (!value.startsWith("'") || !value.endsWith("'") || value.length < 2) return value;
290+
return value.slice(1, -1).replaceAll("'\\''", "'");
291+
}
292+
276293
function androidDeviceStateAdbResult(
277294
key: string,
278295
args: string[],

0 commit comments

Comments
 (0)