From 08109292ae42c7095bf8e7e442ea1c24adf5a3ff Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 17:52:39 -0700 Subject: [PATCH 1/4] [build-tools] Check Android launch inputs before the emulator boots Agent-device sessions checked Android launch inputs only when launching, after the emulator booted and the build installed. Check NUL characters and the command length at the start of the step, as on iOS, leaving room for the launch component that is only known after install. --- .../__tests__/launchApplication.test.ts | 21 +++++++++++- .../__tests__/startAgentDeviceSession.test.ts | 12 +++++++ .../src/steps/functions/launchApplication.ts | 33 +++++++++++++++++-- .../functions/startAgentDeviceSession.ts | 3 ++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/launchApplication.test.ts b/packages/build-tools/src/steps/functions/__tests__/launchApplication.test.ts index 13cc5af2b2..25c8119d9e 100644 --- a/packages/build-tools/src/steps/functions/__tests__/launchApplication.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/launchApplication.test.ts @@ -4,7 +4,11 @@ import { spawnSync } from 'node:child_process'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { createMockLogger } from '../../../__tests__/utils/logger'; -import { createLaunchApplicationFunction, launchApplicationAsync } from '../launchApplication'; +import { + createLaunchApplicationFunction, + launchApplicationAsync, + validateAndroidLaunchOptions, +} from '../launchApplication'; jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, @@ -445,6 +449,21 @@ describe(launchApplicationAsync, () => { expect(mockedSpawn).toHaveBeenCalledTimes(1); }); + it('leaves room for the launch component when validating Android launch inputs early', () => { + const fixedLength = 'am start --es key -n'.length; + const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)]; + + expect(() => + validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - 512 - fixedLength) }) + ).not.toThrow(); + expect(() => + validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - 512 - fixedLength + 1) }) + ).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })); + expect(() => + validateAndroidLaunchOptions({ launchArgs: [], openUrl: 'exp://example.test/a\0b' }) + ).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })); + }); + it('rejects an Android launch command that is too long after quoting', async () => { await expect( launchApplicationAsync({ diff --git a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts index 15d01dd6c8..3661d164b2 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts @@ -687,6 +687,18 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); }); + it.each([ + ['NUL argument', { launch_args: ['a\u0000b'] }], + ['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }], + ['NUL URL', { open_url: 'example:\u0000screen' }], + ])('rejects Android launch %s before startup work', async (_name, inputs) => { + const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs }); + await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }); + expect(startAndroidEmulatorAsync).not.toHaveBeenCalled(); + expect(downloadBuildAsync).not.toHaveBeenCalled(); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); + }); + it('keeps Android launch inputs on its existing direct path', async () => { const launchArgs = ['a'.repeat(9000)]; await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs }); diff --git a/packages/build-tools/src/steps/functions/launchApplication.ts b/packages/build-tools/src/steps/functions/launchApplication.ts index 6146ea3a11..cd5f296fcb 100644 --- a/packages/build-tools/src/steps/functions/launchApplication.ts +++ b/packages/build-tools/src/steps/functions/launchApplication.ts @@ -13,6 +13,8 @@ const IOS_URL_SCHEME_APPROVAL_DOMAIN = 'com.apple.launchservices.schemeapproval' const IOS_URL_SCHEME_APPROVAL_KEY_PREFIX = 'com.apple.CoreSimulator.CoreSimulatorBridge-->'; // adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35). const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000; +// Room for ` /` when the launch component is not known yet. +const ANDROID_COMPONENT_RESERVED_BYTES = 512; export function createLaunchApplicationFunction(): BuildFunction { return new BuildFunction({ @@ -135,12 +137,36 @@ export async function launchApplicationAsync({ } } +// Checks Android launch inputs before the emulator boots. The launch component is only known once +// the build is installed, so room is left for it; the launch itself checks the exact command. +export function validateAndroidLaunchOptions({ + launchArgs = [], + openUrl, +}: { + launchArgs?: string[]; + openUrl?: string; +}): void { + quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n'], { + reservedBytes: ANDROID_COMPONENT_RESERVED_BYTES, + }); + if (openUrl) { + quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]); + } +} + // `adb shell` joins its arguments with spaces and the device shell parses the result again, so // each argument is quoted to reach the command as one literal word. async function spawnAndroidShellCommandAsync( command: string[], { env, logger }: { env: BuildStepEnv; logger: bunyan } ): Promise { + await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger }); +} + +function quoteAndroidShellCommand( + command: string[], + { reservedBytes = 0 }: { reservedBytes?: number } = {} +): string[] { if (command.some(argument => argument.includes('\0'))) { throw new UserError( 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', @@ -148,13 +174,16 @@ async function spawnAndroidShellCommandAsync( ); } const quotedCommand = command.map(quoteAndroidShellArgument); - if (Buffer.byteLength(quotedCommand.join(' ')) > ANDROID_SHELL_COMMAND_MAX_BYTES) { + if ( + Buffer.byteLength(quotedCommand.join(' ')) + reservedBytes > + ANDROID_SHELL_COMMAND_MAX_BYTES + ) { throw new UserError( 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', `The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".` ); } - await spawn('adb', ['shell', ...quotedCommand], { env, logger }); + return quotedCommand; } function quoteAndroidShellArgument(argument: string): string { diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts index a2cc2535de..e57419bdf6 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -43,6 +43,7 @@ import { launchApplicationAsync, parseLaunchArgsInput, parseOpenUrlInput, + validateAndroidLaunchOptions, } from './launchApplication'; import { getAgentDeviceRemoteSessionEnvOrThrow, @@ -164,6 +165,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte } if (isIos) { validateServeSimLaunchOptions({ launchArgs, openUrl }); + } else { + validateAndroidLaunchOptions({ launchArgs, openUrl }); } const deviceIdentifier = inputs.device_identifier.value as string | undefined; const capture = parseNetworkCaptureInputs( From bd78eee1dfe48ab983e865c2a41272ed655bdbf5 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 17:53:46 -0700 Subject: [PATCH 2/4] Add changeset for early Android launch input checks --- .changeset/android-launch-arg-early-validation.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/android-launch-arg-early-validation.md diff --git a/.changeset/android-launch-arg-early-validation.md b/.changeset/android-launch-arg-early-validation.md new file mode 100644 index 0000000000..4441966d36 --- /dev/null +++ b/.changeset/android-launch-arg-early-validation.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': patch +--- + +Check Android launch arguments for NUL characters and length before the emulator boots in agent-device simulator sessions, so invalid input fails right away instead of after the build installs. From 6a95b4ba365f10833f4b1e63670e7461167504e6 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 18:16:53 -0700 Subject: [PATCH 3/4] [eas-build-job] Share the Android launch input checks Move the Android shell quoting and the early launch input checks into @expo/eas-build-job, so www can check launch inputs when a session is created, for every session type. --- .../android-launch-arg-early-validation.md | 3 +- .../__tests__/launchApplication.test.ts | 21 +------ .../src/steps/functions/launchApplication.ts | 55 +---------------- .../functions/startAgentDeviceSession.ts | 3 +- .../src/__tests__/deviceRunSession.test.ts | 41 ++++++++++++- .../eas-build-job/src/deviceRunSession.ts | 60 +++++++++++++++++++ 6 files changed, 105 insertions(+), 78 deletions(-) diff --git a/.changeset/android-launch-arg-early-validation.md b/.changeset/android-launch-arg-early-validation.md index 4441966d36..acf103226b 100644 --- a/.changeset/android-launch-arg-early-validation.md +++ b/.changeset/android-launch-arg-early-validation.md @@ -1,5 +1,6 @@ --- +'@expo/eas-build-job': minor '@expo/build-tools': patch --- -Check Android launch arguments for NUL characters and length before the emulator boots in agent-device simulator sessions, so invalid input fails right away instead of after the build installs. +Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. diff --git a/packages/build-tools/src/steps/functions/__tests__/launchApplication.test.ts b/packages/build-tools/src/steps/functions/__tests__/launchApplication.test.ts index 25c8119d9e..13cc5af2b2 100644 --- a/packages/build-tools/src/steps/functions/__tests__/launchApplication.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/launchApplication.test.ts @@ -4,11 +4,7 @@ import { spawnSync } from 'node:child_process'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { createMockLogger } from '../../../__tests__/utils/logger'; -import { - createLaunchApplicationFunction, - launchApplicationAsync, - validateAndroidLaunchOptions, -} from '../launchApplication'; +import { createLaunchApplicationFunction, launchApplicationAsync } from '../launchApplication'; jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, @@ -449,21 +445,6 @@ describe(launchApplicationAsync, () => { expect(mockedSpawn).toHaveBeenCalledTimes(1); }); - it('leaves room for the launch component when validating Android launch inputs early', () => { - const fixedLength = 'am start --es key -n'.length; - const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)]; - - expect(() => - validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - 512 - fixedLength) }) - ).not.toThrow(); - expect(() => - validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - 512 - fixedLength + 1) }) - ).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })); - expect(() => - validateAndroidLaunchOptions({ launchArgs: [], openUrl: 'exp://example.test/a\0b' }) - ).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })); - }); - it('rejects an Android launch command that is too long after quoting', async () => { await expect( launchApplicationAsync({ diff --git a/packages/build-tools/src/steps/functions/launchApplication.ts b/packages/build-tools/src/steps/functions/launchApplication.ts index cd5f296fcb..cebaecd0f0 100644 --- a/packages/build-tools/src/steps/functions/launchApplication.ts +++ b/packages/build-tools/src/steps/functions/launchApplication.ts @@ -1,4 +1,4 @@ -import { UserError } from '@expo/eas-build-job'; +import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; import { BuildFunction, @@ -11,10 +11,6 @@ import spawn from '@expo/turtle-spawn'; const IOS_URL_SCHEME_APPROVAL_DOMAIN = 'com.apple.launchservices.schemeapproval'; const IOS_URL_SCHEME_APPROVAL_KEY_PREFIX = 'com.apple.CoreSimulator.CoreSimulatorBridge-->'; -// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35). -const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000; -// Room for ` /` when the launch component is not known yet. -const ANDROID_COMPONENT_RESERVED_BYTES = 512; export function createLaunchApplicationFunction(): BuildFunction { return new BuildFunction({ @@ -137,25 +133,6 @@ export async function launchApplicationAsync({ } } -// Checks Android launch inputs before the emulator boots. The launch component is only known once -// the build is installed, so room is left for it; the launch itself checks the exact command. -export function validateAndroidLaunchOptions({ - launchArgs = [], - openUrl, -}: { - launchArgs?: string[]; - openUrl?: string; -}): void { - quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n'], { - reservedBytes: ANDROID_COMPONENT_RESERVED_BYTES, - }); - if (openUrl) { - quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]); - } -} - -// `adb shell` joins its arguments with spaces and the device shell parses the result again, so -// each argument is quoted to reach the command as one literal word. async function spawnAndroidShellCommandAsync( command: string[], { env, logger }: { env: BuildStepEnv; logger: bunyan } @@ -163,36 +140,6 @@ async function spawnAndroidShellCommandAsync( await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger }); } -function quoteAndroidShellCommand( - command: string[], - { reservedBytes = 0 }: { reservedBytes?: number } = {} -): string[] { - if (command.some(argument => argument.includes('\0'))) { - throw new UserError( - 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', - 'Android "launch_args" and "open_url" cannot contain NUL characters.' - ); - } - const quotedCommand = command.map(quoteAndroidShellArgument); - if ( - Buffer.byteLength(quotedCommand.join(' ')) + reservedBytes > - ANDROID_SHELL_COMMAND_MAX_BYTES - ) { - throw new UserError( - 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', - `The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".` - ); - } - return quotedCommand; -} - -function quoteAndroidShellArgument(argument: string): string { - if (/^[\w@%+=:,./-]+$/.test(argument)) { - return argument; - } - return `'${argument.replaceAll("'", `'\\''`)}'`; -} - async function preapproveIosUrlSchemeAsync({ applicationIdentifier, openUrl, diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts index e57419bdf6..472efce41a 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -1,4 +1,4 @@ -import { UserError } from '@expo/eas-build-job'; +import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job'; import { BuildFunction, BuildRuntimePlatform, @@ -43,7 +43,6 @@ import { launchApplicationAsync, parseLaunchArgsInput, parseOpenUrlInput, - validateAndroidLaunchOptions, } from './launchApplication'; import { getAgentDeviceRemoteSessionEnvOrThrow, diff --git a/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts b/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts index 4e6524d40f..d2a059a061 100644 --- a/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts +++ b/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts @@ -1,4 +1,9 @@ -import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession'; +import { + DeviceRunSessionEvent, + parseDeviceRunSessionEvents, + quoteAndroidShellCommand, + validateAndroidLaunchOptions, +} from '../deviceRunSession'; describe('parseDeviceRunSessionEvents', () => { it('parses, deduplicates, and sorts events from the dedicated event log', () => { @@ -33,6 +38,40 @@ describe('parseDeviceRunSessionEvents', () => { }); }); +describe(quoteAndroidShellCommand, () => { + it('quotes only arguments the device shell would split or expand', () => { + expect( + quoteAndroidShellCommand(['am', 'start', '--es', 'text', 'hello world', "it's", '', '-n']) + ).toEqual(['am', 'start', '--es', 'text', "'hello world'", "'it'\\''s'", "''", '-n']); + }); + + it('rejects NUL characters', () => { + expect(() => quoteAndroidShellCommand(['am', 'start', 'a\0b'])).toThrow( + expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }) + ); + }); +}); + +describe(validateAndroidLaunchOptions, () => { + it('leaves room for the launch component', () => { + const fixedLength = 'am start --es key -n'.length; + const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)]; + + expect(() => + validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - 512 - fixedLength) }) + ).not.toThrow(); + expect(() => + validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - 512 - fixedLength + 1) }) + ).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })); + }); + + it('checks the URL to open', () => { + expect(() => validateAndroidLaunchOptions({ openUrl: 'exp://example.test/a\0b' })).toThrow( + expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }) + ); + }); +}); + function createEvent(overrides: Partial = {}): string { return JSON.stringify(createEventValue(overrides)); } diff --git a/packages/eas-build-job/src/deviceRunSession.ts b/packages/eas-build-job/src/deviceRunSession.ts index fa5e9c224f..5962542b39 100644 --- a/packages/eas-build-job/src/deviceRunSession.ts +++ b/packages/eas-build-job/src/deviceRunSession.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { UserError } from './errors'; + /** * Canonical record format for the activity events emitted during a remote device run (simulator) * session. Producers (e.g. the worker and agent-device) write these as NDJSON to the @@ -46,3 +48,61 @@ export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionE (left, right) => new Date(left.ts).getTime() - new Date(right.ts).getTime() ); } + +// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35). +export const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000; +// Room for ` /` when the launch component is not known yet. +const ANDROID_COMPONENT_RESERVED_BYTES = 512; + +/** + * Check Android launch inputs before a session starts. The launch component is only known once the + * build is installed, so room is left for it; the launch itself checks the exact command. + */ +export function validateAndroidLaunchOptions({ + launchArgs = [], + openUrl, +}: { + launchArgs?: string[]; + openUrl?: string; +}): void { + quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n'], { + reservedBytes: ANDROID_COMPONENT_RESERVED_BYTES, + }); + if (openUrl) { + quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]); + } +} + +/** + * `adb shell` joins its arguments with spaces and the device shell parses the result again, so + * each argument is quoted to reach the command as one literal word. + */ +export function quoteAndroidShellCommand( + command: string[], + { reservedBytes = 0 }: { reservedBytes?: number } = {} +): string[] { + if (command.some(argument => argument.includes('\0'))) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'Android "launch_args" and "open_url" cannot contain NUL characters.' + ); + } + const quotedCommand = command.map(quoteAndroidShellArgument); + if ( + new TextEncoder().encode(quotedCommand.join(' ')).byteLength + reservedBytes > + ANDROID_SHELL_COMMAND_MAX_BYTES + ) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + `The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".` + ); + } + return quotedCommand; +} + +function quoteAndroidShellArgument(argument: string): string { + if (/^[\w@%+=:,./-]+$/.test(argument)) { + return argument; + } + return `'${argument.replaceAll("'", `'\\''`)}'`; +} From 8aabf499f41a4c029d78217214ef6027c65e9032 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 19:50:42 -0700 Subject: [PATCH 4/4] [eas-build-job] Check only the known part of the Android launch command early Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/deviceRunSession.test.ts | 22 ++++++++++++++++--- .../eas-build-job/src/deviceRunSession.ts | 17 +++++--------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts b/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts index d2a059a061..eb033a41e3 100644 --- a/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts +++ b/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts @@ -53,18 +53,34 @@ describe(quoteAndroidShellCommand, () => { }); describe(validateAndroidLaunchOptions, () => { - it('leaves room for the launch component', () => { + it('rejects only commands that are too long without the launch component', () => { const fixedLength = 'am start --es key -n'.length; const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)]; expect(() => - validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - 512 - fixedLength) }) + validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength) }) ).not.toThrow(); expect(() => - validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - 512 - fixedLength + 1) }) + validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength + 1) }) ).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })); }); + it('counts bytes, not characters', () => { + // "é" is two bytes in UTF-8 and is quoted, which adds two more. + const fixedLength = "am start --es key '' -n".length; + const launchArgs = (extraLength: number) => [ + '--es', + 'key', + 'a'.repeat(extraLength) + 'é'.repeat(32_488), + ]; + + expect(fixedLength + 1 + 2 * 32_488).toBe(65_000); + expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(1) })).not.toThrow(); + expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(2) })).toThrow( + expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }) + ); + }); + it('checks the URL to open', () => { expect(() => validateAndroidLaunchOptions({ openUrl: 'exp://example.test/a\0b' })).toThrow( expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }) diff --git a/packages/eas-build-job/src/deviceRunSession.ts b/packages/eas-build-job/src/deviceRunSession.ts index 5962542b39..8f86014fff 100644 --- a/packages/eas-build-job/src/deviceRunSession.ts +++ b/packages/eas-build-job/src/deviceRunSession.ts @@ -51,12 +51,11 @@ export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionE // adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35). export const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000; -// Room for ` /` when the launch component is not known yet. -const ANDROID_COMPONENT_RESERVED_BYTES = 512; /** * Check Android launch inputs before a session starts. The launch component is only known once the - * build is installed, so room is left for it; the launch itself checks the exact command. + * build is installed, so this rejects only commands that are too long without it; the launch itself + * checks the exact command. */ export function validateAndroidLaunchOptions({ launchArgs = [], @@ -65,9 +64,7 @@ export function validateAndroidLaunchOptions({ launchArgs?: string[]; openUrl?: string; }): void { - quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n'], { - reservedBytes: ANDROID_COMPONENT_RESERVED_BYTES, - }); + quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n']); if (openUrl) { quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]); } @@ -77,10 +74,7 @@ export function validateAndroidLaunchOptions({ * `adb shell` joins its arguments with spaces and the device shell parses the result again, so * each argument is quoted to reach the command as one literal word. */ -export function quoteAndroidShellCommand( - command: string[], - { reservedBytes = 0 }: { reservedBytes?: number } = {} -): string[] { +export function quoteAndroidShellCommand(command: string[]): string[] { if (command.some(argument => argument.includes('\0'))) { throw new UserError( 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', @@ -89,8 +83,7 @@ export function quoteAndroidShellCommand( } const quotedCommand = command.map(quoteAndroidShellArgument); if ( - new TextEncoder().encode(quotedCommand.join(' ')).byteLength + reservedBytes > - ANDROID_SHELL_COMMAND_MAX_BYTES + new TextEncoder().encode(quotedCommand.join(' ')).byteLength > ANDROID_SHELL_COMMAND_MAX_BYTES ) { throw new UserError( 'EAS_LAUNCH_APPLICATION_INVALID_INPUT',