diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index 8cca9cd736..7ca52fcaa2 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -353,7 +353,8 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['--activity'], type: 'string', usageLabel: '--activity ', - usageDescription: 'Android app launch activity (package/Activity); not for URL opens', + usageDescription: + 'Android launch activity (package/Class, .Class, or Class) or HarmonyOS ability name; not for URL opens', projectConfig: true, recorded: false, }, diff --git a/packages/kernel/src/device-shell.test.ts b/packages/kernel/src/device-shell.test.ts index 26d1cdc388..0f5a9a2596 100644 --- a/packages/kernel/src/device-shell.test.ts +++ b/packages/kernel/src/device-shell.test.ts @@ -3,6 +3,7 @@ import { assertDeviceShellArgv, deviceShellArgv, deviceShellExecutableOf, + relayDeviceShellArgvWithOptions, relayDeviceShellArgvWithoutOptions, shellFragment, } from './device-shell.ts'; @@ -145,6 +146,23 @@ describe('assertDeviceShellArgv', () => { expect(() => assertDeviceShellArgv(payload, 'test')).not.toThrow(); }); + it('keeps a command minted when a transport lowers addressing in front of it', () => { + const lowered = relayDeviceShellArgvWithOptions( + ['-s', 'emulator-5554'], + deviceShellArgv('adb', 'shell', ['id'], ['wait-for-device']), + ); + expect(lowered).toEqual(['-s', 'emulator-5554', 'wait-for-device', 'shell', 'id']); + expect(() => assertDeviceShellArgv(lowered, 'test')).not.toThrow(); + expect(relayDeviceShellArgvWithoutOptions(lowered, 0, 3)).toEqual(['shell', 'id']); + expect(() => + assertDeviceShellArgv(relayDeviceShellArgvWithoutOptions(lowered, 0, 3), 'test'), + ).not.toThrow(); + const raw = ['shell', 'am start -n x;id']; + expect(() => + assertDeviceShellArgv(relayDeviceShellArgvWithOptions(['-s', 'emulator-5554'], raw), 'test'), + ).toThrow(UNGUARDED); + }); + it('refuses a relay that reaches the device command, and one that never had a minted command', () => { const addressed = deviceShellArgv('adb', 'shell', ['id'], ['-s', 'emulator-5554']); const truncated = relayDeviceShellArgvWithoutOptions(addressed, 3, 1); diff --git a/packages/kernel/src/device-shell.ts b/packages/kernel/src/device-shell.ts index 8195f03a53..fb042c66dd 100644 --- a/packages/kernel/src/device-shell.ts +++ b/packages/kernel/src/device-shell.ts @@ -158,6 +158,21 @@ export function relayDeviceShellArgvWithoutOptions( return command; } +/** + * A command with transport options put in front of it: the `-P`/`-s` pair and readiness token a + * transport lowers typed addressing into. The words from the subcommand onward are untouched, so a + * minted command stays minted and an argv built outside the funnel comes back unminted. + */ +export function relayDeviceShellArgvWithOptions( + options: readonly string[], + args: readonly string[], +): readonly string[] { + const optionRun = mintedDeviceShellOptionRuns.get(args); + const command = Object.freeze([...options, ...args]); + if (optionRun !== undefined) mintedDeviceShellOptionRuns.set(command, options.length + optionRun); + return command; +} + /** * The dispatch-boundary guard: refuses a `shell`/`exec-out` command that {@link deviceShellArgv} did * not build. Checked on the value, so a variable-built or indirect command is caught the same as a diff --git a/packages/platform-android/src/__tests__/adb-provider-scope.test.ts b/packages/platform-android/src/__tests__/adb-provider-scope.test.ts index 6e4a901927..647cae2e09 100644 --- a/packages/platform-android/src/__tests__/adb-provider-scope.test.ts +++ b/packages/platform-android/src/__tests__/adb-provider-scope.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { test } from 'vitest'; import './test-utils/android-host-test-setup.ts'; import { runCmd } from '@agent-device/host-kit/command'; +import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { withAndroidAdbProvider } from '../adb-executor.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; @@ -29,7 +30,7 @@ test('withAndroidAdbProvider intercepts adb commands for the scoped serial', asy }, { serial: device.id }, async () => - await runCmd('adb', ['-s', 'emulator-5554', 'shell', 'echo', 'ok'], { + await runCmd('adb', deviceShellArgv('adb', 'shell', ['echo', 'ok'], ['-s', device.id]), { allowFailure: true, }), ); diff --git a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts index 5e2fae25c3..8401bcca93 100644 --- a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts +++ b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts @@ -578,6 +578,48 @@ test('openAndroidApp appends launchArgs to am start when activity override is se }); }); +test('openAndroidApp refuses an activity outside the component grammar before any adb call', async () => { + const calls: (readonly string[])[] = []; + const device: DeviceInfo = { ...ANDROID_EMULATOR, booted: false }; + + for (const activity of [ + '.Main;id', + '.Main $(id)', + 'com.example.app/.Main`id`', + "com.example.app/.Main' -e x y", + 'com.example.app/.Main\nreboot', + 'com.example.app//.Main', + 'com..example/.Main', + '9com.example/.Main', + '', + ]) { + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }, + { serial: device.id }, + async () => + await assertRejectsAppError(() => openAndroidApp(device, 'com.example.app', { activity }), { + code: 'INVALID_ARGS', + reason: 'invalid-android-activity-component', + }), + ); + } + assert.deepEqual(calls, []); +}); + +test('openAndroidApp accepts a fully qualified nested-class activity component', async () => { + await withFakeAdb(androidOpenFakeAdb, async ({ calls, device }) => { + await openAndroidApp(device, 'com.example.app', { + activity: 'com.example.app/com.example.app.Outer$Inner_2', + }); + assert.equal(calls.at(-1)?.at(-1), `'com.example.app/com.example.app.Outer$Inner_2'`); + }); +}); + test('openAndroidApp appends launchArgs to am start for deep link URL opens', async () => { await withFakeAdb(androidOpenFakeAdb, async ({ calls, device }) => { await openAndroidApp(device, 'myapp://item/42', { diff --git a/packages/platform-android/src/adb-provider-scope.test.ts b/packages/platform-android/src/adb-provider-scope.test.ts index 326b7eabe6..09de97d713 100644 --- a/packages/platform-android/src/adb-provider-scope.test.ts +++ b/packages/platform-android/src/adb-provider-scope.test.ts @@ -60,7 +60,7 @@ function scoped(serial: string, ...command: string[]) { return { args: ['-s', serial, ...command], serverPort: 15_037, optionServerPort: undefined }; } -function invokedArgv(invocation: AndroidAdbInvocation): string[] { +function invokedArgv(invocation: AndroidAdbInvocation): readonly string[] { return serializeAndroidAdbInvocation({ ...invocation, target: { ...invocation.target, server: { kind: 'ambient' } }, @@ -120,7 +120,7 @@ test('a command adb refused as device offline waits for the device and runs once let offline = true; bindAndroidAdbHostStub({ execAdb: async (invocation, options) => { - calls.push({ args: invokedArgv(invocation), timeoutMs: options?.timeoutMs }); + calls.push({ args: [...invokedArgv(invocation)], timeoutMs: options?.timeoutMs }); if (isWaitForDevice(invocation)) { offline = false; return ok(); @@ -341,7 +341,7 @@ test('the installed override routes only normalized device-scoped adb calls to t // An override-capturing host observes the scope's routing decisions directly. let captured: - | ((cmd: string, args: string[], options: object) => Promise | undefined) + | ((cmd: string, args: readonly string[], options: object) => Promise | undefined) | undefined; bindAndroidAdbHostStub({ withAdbCommandExecutorOverride: async (override, fn) => { @@ -350,7 +350,9 @@ test('the installed override routes only normalized device-scoped adb calls to t }, }); await withAndroidAdbProvider(provider, { serial: DEVICE.id }, async () => { - expect(captured?.('adb', ['-s', DEVICE.id, 'shell', 'ls'], {})).toBeDefined(); + expect( + captured?.('adb', deviceShellArgv('adb', 'shell', ['ls'], ['-s', DEVICE.id]), {}), + ).toBeDefined(); expect(captured?.('adb', ['-s', OTHER.id, 'shell', 'ls'], {})).toBeUndefined(); expect(captured?.('adb', ['devices'], {})).toBeUndefined(); expect(captured?.('emulator', ['-list-avds'], {})).toBeUndefined(); @@ -358,6 +360,35 @@ test('the installed override routes only normalized device-scoped adb calls to t expect(providerCalls).toEqual([['shell', 'ls']]); }); +test('the installed override refuses a device-shell argv the funnel did not mint', async () => { + const providerCalls: (readonly string[])[] = []; + let captured: + | ((cmd: string, args: readonly string[], options: object) => Promise | undefined) + | undefined; + bindAndroidAdbHostStub({ + withAdbCommandExecutorOverride: async (override, fn) => { + captured = override; + return await fn(); + }, + }); + await withAndroidAdbProvider( + { + exec: async (args) => { + providerCalls.push(args); + return ok(); + }, + }, + { serial: DEVICE.id }, + async () => { + const joined = ['am', 'start', '-n', 'com.example/.Main;id'].join(' '); + await expect(captured?.('adb', ['-s', DEVICE.id, 'shell', joined], {})).rejects.toMatchObject( + { code: 'INVALID_ARGS', details: { reason: 'unguarded-device-shell-argv' } }, + ); + }, + ); + expect(providerCalls).toEqual([]); +}); + test('a managed port scope refuses global options the provider cannot restate', async () => { const providerCalls: (readonly string[])[] = []; const provider: AndroidAdbProvider = { @@ -366,9 +397,12 @@ test('a managed port scope refuses global options the provider cannot restate', return ok(); }, }; - const capture = async (scope: { serial: string; serverPort?: number }, args: string[]) => { + const capture = async ( + scope: { serial: string; serverPort?: number }, + args: readonly string[], + ) => { let captured: - | ((cmd: string, args: string[], options: object) => Promise | undefined) + | ((cmd: string, args: readonly string[], options: object) => Promise | undefined) | undefined; bindAndroidAdbHostStub({ withAdbCommandExecutorOverride: async (override, fn) => { @@ -412,21 +446,23 @@ test('a managed port scope refuses global options the provider cannot restate', // The server this lease holds is the one port the provider may be handed a request for, argv // included, and the request still travels as the caller wrote it. - await capture({ serial: DEVICE.id, serverPort: 15_037 }, [ - '-P', - '15037', - '-s', - DEVICE.id, - 'shell', - 'ls', - ]); + await capture( + { serial: DEVICE.id, serverPort: 15_037 }, + deviceShellArgv('adb', 'shell', ['ls'], ['-P', '15037', '-s', DEVICE.id]), + ); expect(providerCalls).toEqual([['-P', '15037', 'shell', 'ls']]); // Without a lease the caller's own adb invocation is what runs, globals and all: the provider // receives the request with only this scope's `-s` pair removed, and no server rule applies. providerCalls.length = 0; - await capture({ serial: DEVICE.id }, ['-t', '42', '-s', DEVICE.id, 'shell', 'ls']); - await capture({ serial: DEVICE.id }, ['-P', '9999', '-s', DEVICE.id, 'shell', 'ls']); + await capture( + { serial: DEVICE.id }, + deviceShellArgv('adb', 'shell', ['ls'], ['-t', '42', '-s', DEVICE.id]), + ); + await capture( + { serial: DEVICE.id }, + deviceShellArgv('adb', 'shell', ['ls'], ['-P', '9999', '-s', DEVICE.id]), + ); expect(providerCalls).toEqual([ ['-t', '42', 'shell', 'ls'], ['-P', '9999', 'shell', 'ls'], @@ -443,7 +479,7 @@ test('the provider receives the caller request with only the scope serial remove }, }; let captured: - | ((cmd: string, args: string[], options: object) => Promise | undefined) + | ((cmd: string, args: readonly string[], options: object) => Promise | undefined) | undefined; bindAndroidAdbHostStub({ execAdb: async (invocation, options) => { @@ -459,13 +495,17 @@ test('the provider receives the caller request with only the scope serial remove await withAndroidAdbProvider(provider, { serial: DEVICE.id, serverPort: 15_037 }, async () => { // A readiness token is part of the request the provider must honor, not addressing it owns, // so it travels ahead of the command instead of being parsed away. - captured?.('adb', ['-s', DEVICE.id, 'wait-for-device', 'shell', 'getprop'], {}); + captured?.( + 'adb', + deviceShellArgv('adb', 'shell', ['getprop'], ['-s', DEVICE.id, 'wait-for-device']), + {}, + ); // A call that addresses no device is not the provider's to answer as a device command. captured?.('adb', ['get-state'], {}); }); // A transport global under a lease is refused by the test above, not restated here. await withAndroidAdbProvider(provider, { serial: DEVICE.id }, async () => { - captured?.('adb', ['-d', '-s', DEVICE.id, 'shell', 'getprop'], {}); + captured?.('adb', deviceShellArgv('adb', 'shell', ['getprop'], ['-d', '-s', DEVICE.id]), {}); }); expect(providerCalls).toEqual([ @@ -527,7 +567,7 @@ test('a managed port scope classifies absolute adb commands and preserves the de const providerCalls: (readonly string[])[] = []; const hostCalls: (readonly string[])[] = []; let captured: - | ((cmd: string, args: string[], options: object) => Promise | undefined) + | ((cmd: string, args: readonly string[], options: object) => Promise | undefined) | undefined; bindAndroidAdbHostStub({ execAdb: async (invocation) => { @@ -552,7 +592,7 @@ test('a managed port scope classifies absolute adb commands and preserves the de const global = captured?.('/opt/android-sdk/platform-tools/adb', ['devices', '-l'], {}); const matching = captured?.( '/opt/android-sdk/platform-tools/adb', - ['-s', DEVICE.id, 'shell', 'ls'], + deviceShellArgv('adb', 'shell', ['ls'], ['-s', DEVICE.id]), {}, ); expect(() => captured?.('adb', ['-s', OTHER.id, 'shell', 'ls'], {})).toThrowError( @@ -716,7 +756,7 @@ test('a device route answers for the server it was built with, not one a call na test('a managed port scope keeps shell -s arguments on the private transport', async () => { const hostCalls: ReturnType[] = []; let captured: - | ((cmd: string, args: string[], options: object) => Promise | undefined) + | ((cmd: string, args: readonly string[], options: object) => Promise | undefined) | undefined; bindAndroidAdbHostStub({ execAdb: async (invocation, options) => { diff --git a/packages/platform-android/src/adb-provider-scope.ts b/packages/platform-android/src/adb-provider-scope.ts index 564671615d..892ae9bd46 100644 --- a/packages/platform-android/src/adb-provider-scope.ts +++ b/packages/platform-android/src/adb-provider-scope.ts @@ -407,6 +407,7 @@ export async function withAndroidAdbProvider( function createAndroidCommandExecutorOverride( scope: AndroidAdbProviderScope, ): AndroidAdbCommandExecutorOverride { + const exec = guardDeviceShell(scope.provider.exec); return (cmd, args, options) => { if (!isAdbCommand(cmd)) return undefined; if (scope.serverPort === undefined && cmd !== 'adb') return undefined; @@ -427,7 +428,7 @@ function createAndroidCommandExecutorOverride( const payload = androidAdbPayloadWithoutSerial(args, scope.serial); if (payload === undefined) return undefined; return requireAndroidAdbHost().withoutAdbCommandExecutorOverride( - async () => await scope.provider.exec(payload, options), + async () => await exec(payload, options), ); } const port = scope.serverPort; diff --git a/packages/platform-android/src/adb-transport.ts b/packages/platform-android/src/adb-transport.ts index 30b65a8ae0..991d3af7ea 100644 --- a/packages/platform-android/src/adb-transport.ts +++ b/packages/platform-android/src/adb-transport.ts @@ -1,4 +1,7 @@ -import { relayDeviceShellArgvWithoutOptions } from '@agent-device/kernel/device-shell'; +import { + relayDeviceShellArgvWithOptions, + relayDeviceShellArgvWithoutOptions, +} from '@agent-device/kernel/device-shell'; import { AppError } from '@agent-device/kernel/errors'; import type { Readable, Stream, Writable } from 'node:stream'; import type { Rect } from '@agent-device/kernel/snapshot'; @@ -345,16 +348,15 @@ export function adoptAndroidAdbSerial( * The only place adb global options are emitted for an invocation whose addressing was rewritten. * `command` is appended, never re-parsed. */ -export function serializeAndroidAdbInvocation(invocation: AndroidAdbInvocation): string[] { - if (invocation.rawArgv) return [...invocation.rawArgv]; +export function serializeAndroidAdbInvocation(invocation: AndroidAdbInvocation): readonly string[] { + if (invocation.rawArgv) return relayDeviceShellArgvWithOptions([], invocation.rawArgv); const { target, command } = invocation; const serialized: string[] = []; if (target.server.kind === 'port') serialized.push('-P', String(target.server.port)); if (target.selector.kind === 'serial') serialized.push('-s', target.selector.serial); if (target.hostGlobals) serialized.push(...target.hostGlobals); if (target.waitFor) serialized.push(target.waitFor); - serialized.push(...command); - return serialized; + return relayDeviceShellArgvWithOptions(serialized, command); } const OWNED_OPTIONS: Readonly> = { @@ -631,7 +633,7 @@ export function lowerAndroidAdbInvocation, -): { args: string[]; options: Omit } { +): { args: readonly string[]; options: Omit } { const { serverPort: _requestedServerPort, ...execOptions } = options ?? ({} as Options); const port = requireAndroidAdbServerPort(invocation, options); const resolved = diff --git a/packages/platform-android/src/app-control.test.ts b/packages/platform-android/src/app-control.test.ts new file mode 100644 index 0000000000..4ce13c169f --- /dev/null +++ b/packages/platform-android/src/app-control.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'vitest'; +import type { AndroidAdbExecutor } from './adb-executor.ts'; +import { openAndroidAppWithAdb } from './app-control.ts'; + +function recordingAdb(): { adb: AndroidAdbExecutor; calls: (readonly string[])[] } { + const calls: (readonly string[])[] = []; + return { + calls, + adb: async (args) => { + calls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; +} + +test('openAndroidAppWithAdb launches the activity override as a package-relative component', async () => { + const { adb, calls } = recordingAdb(); + await openAndroidAppWithAdb(adb, 'com.example.app', { activity: 'MainActivity' }); + expect(calls.at(-1)?.slice(-2)).toEqual(['-n', 'com.example.app/.MainActivity']); +}); + +test('openAndroidAppWithAdb refuses an activity outside the component grammar before any adb call', async () => { + const { adb, calls } = recordingAdb(); + for (const activity of ['.Main;id', 'com..example/.Main', '']) { + await expect(openAndroidAppWithAdb(adb, 'com.example.app', { activity })).rejects.toMatchObject( + { + code: 'INVALID_ARGS', + details: { reason: 'invalid-android-activity-component', activity }, + }, + ); + } + expect(calls).toEqual([]); +}); diff --git a/packages/platform-android/src/app-control.ts b/packages/platform-android/src/app-control.ts index dc5fb5c838..b08d6793af 100644 --- a/packages/platform-android/src/app-control.ts +++ b/packages/platform-android/src/app-control.ts @@ -1,7 +1,11 @@ import { AppError } from '@agent-device/kernel/errors'; import type { AndroidAdbExecutor } from './adb-executor.ts'; import { runAdbShell } from './adb-executor.ts'; -import { isAmStartError, parseAndroidLaunchComponent } from './app-lifecycle.ts'; +import { + androidActivityComponent, + isAmStartError, + parseAndroidLaunchComponent, +} from './app-lifecycle.ts'; const ANDROID_LAUNCHER_CATEGORY = 'android.intent.category.LAUNCHER'; const ANDROID_DEFAULT_CATEGORY = 'android.intent.category.DEFAULT'; @@ -52,7 +56,7 @@ export async function openAndroidAppWithAdb( options: AndroidOpenAppWithAdbOptions = {}, ): Promise { const category = options.category ?? ANDROID_LAUNCHER_CATEGORY; - if (options.activity) { + if (options.activity !== undefined) { await runAdbShell(adb, [ 'am', 'start', @@ -64,7 +68,7 @@ export async function openAndroidAppWithAdb( '-c', category, '-n', - normalizeAndroidComponent(packageName, options.activity), + androidActivityComponent(packageName, options.activity), ]); return; } @@ -118,8 +122,3 @@ export async function openAndroidAppWithAdb( component, ]); } - -function normalizeAndroidComponent(packageName: string, activity: string): string { - if (activity.includes('/')) return activity; - return `${packageName}/${activity.startsWith('.') ? activity : `.${activity}`}`; -} diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index 8019768135..c3ae10b6e8 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -160,6 +160,34 @@ export type OpenAndroidAppOptions = { url?: string; }; +const ANDROID_PACKAGE_NAME = String.raw`[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*`; +const JAVA_IDENTIFIER = String.raw`[A-Za-z_$][A-Za-z0-9_$]*`; + +/** + * `am start -n` takes `/`: manifest package segments start with a letter, and class + * segments are Java identifiers, `$` included for a nested class. A bare class (`.Main` or `Main`) + * is resolved against the launched package. + */ +const ANDROID_ACTIVITY_COMPONENT = new RegExp( + `^(?:${ANDROID_PACKAGE_NAME}/)?\\.?${JAVA_IDENTIFIER}(?:\\.${JAVA_IDENTIFIER})*$`, +); + +function requireAndroidActivityComponent(activity: string): void { + if (ANDROID_ACTIVITY_COMPONENT.test(activity)) return; + throw new AppError('INVALID_ARGS', `Invalid Android activity component: ${activity}`, { + reason: 'invalid-android-activity-component', + activity, + hint: 'Pass --activity as /, ., or , using letters, digits, `_`, `.`, and `$`.', + }); +} + +/** The `am start -n` component for an activity override, refused unless it is a component name. */ +export function androidActivityComponent(packageName: string, activity: string): string { + requireAndroidActivityComponent(activity); + if (activity.includes('/')) return activity; + return `${packageName}/${activity.startsWith('.') ? activity : `.${activity}`}`; +} + function androidLaunchArgs(options: OpenAndroidAppOptions): string[] { return options.launchArgs ?? []; } @@ -169,11 +197,12 @@ export async function openAndroidApp( app: string, optionsOrActivity?: OpenAndroidAppOptions | string, ): Promise { + const options = normalizeOpenAndroidAppOptions(optionsOrActivity); + const activity = options.activity; + if (activity !== undefined) requireAndroidActivityComponent(activity); if (!device.booted) { await waitForAndroidBoot(device.id); } - const options = normalizeOpenAndroidAppOptions(optionsOrActivity); - const activity = options.activity; const deepLinkTarget = app.trim(); if (isDeepLinkTarget(deepLinkTarget)) { await openAndroidDeepLink(device, deepLinkTarget, options); @@ -270,9 +299,7 @@ async function openAndroidPackageActivity( launchCategory: string, options: OpenAndroidAppOptions, ): Promise { - const component = activity.includes('/') - ? activity - : `${packageName}/${activity.startsWith('.') ? activity : `.${activity}`}`; + const component = androidActivityComponent(packageName, activity); try { await runAndroidShell( device, diff --git a/packages/platform-harmonyos/src/__tests__/app-lifecycle.test.ts b/packages/platform-harmonyos/src/__tests__/app-lifecycle.test.ts index 698d7bb803..7a39db79d9 100644 --- a/packages/platform-harmonyos/src/__tests__/app-lifecycle.test.ts +++ b/packages/platform-harmonyos/src/__tests__/app-lifecycle.test.ts @@ -159,6 +159,20 @@ test('HarmonyOS lifecycle commands use HDC bundle and ability primitives', async assert.deepEqual(invalidateHarmonyGestureViewport.mock.calls, [[DEVICE], [DEVICE]]); }); +test('openHarmonyApp refuses an ability name outside the module grammar before any hdc call', async () => { + for (const activity of ['Entry$(id)', 'Entry;reboot', 'Entry Ability', '1Entry', 'Entry"x', '']) { + await assert.rejects( + () => openHarmonyApp(DEVICE, 'com.example.application', { activity }), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.details?.reason === 'invalid-harmony-ability-name' && + error.details?.ability === activity, + ); + } + assert.equal(mockRunCmd.mock.calls.length, 0); +}); + test('HarmonyOS app listing filters user-installed bundles through Bundle Manager metadata', async () => { mockRunCmd .mockResolvedValueOnce({ diff --git a/packages/platform-harmonyos/src/app-lifecycle.ts b/packages/platform-harmonyos/src/app-lifecycle.ts index bc1c260cb4..d330c18674 100644 --- a/packages/platform-harmonyos/src/app-lifecycle.ts +++ b/packages/platform-harmonyos/src/app-lifecycle.ts @@ -199,11 +199,24 @@ function combineHarmonySignals( return definedSignals.length === 1 ? definedSignals[0] : AbortSignal.any(definedSignals); } +/** A module.json5 ability name: a letter, then letters, digits, `_`, or `.`. */ +const HARMONY_ABILITY_NAME = /^[A-Za-z][A-Za-z0-9_.]*$/; + +function requireHarmonyAbilityName(ability: string): void { + if (HARMONY_ABILITY_NAME.test(ability)) return; + throw new AppError('INVALID_ARGS', `Invalid HarmonyOS ability name: ${ability}`, { + reason: 'invalid-harmony-ability-name', + ability, + hint: 'Pass --activity as the ability name from module.json5, such as EntryAbility.', + }); +} + export async function openHarmonyApp( device: DeviceInfo, bundleId: string, options?: { activity?: string; signal?: AbortSignal }, ): Promise { + if (options?.activity !== undefined) requireHarmonyAbilityName(options.activity); const launchTarget = options?.activity ? { ability: options.activity } : parseHarmonyLaunchTarget( diff --git a/packages/provider-limrun/src/android.test.ts b/packages/provider-limrun/src/android.test.ts index 17a5296a13..08983d7ab3 100644 --- a/packages/provider-limrun/src/android.test.ts +++ b/packages/provider-limrun/src/android.test.ts @@ -27,7 +27,7 @@ test('cleanup asks the platform to address the disconnect as a server-level comm android: { hostAdbInvocation }, host: { runAdb: async (invocation: AndroidAdbInvocation, options?: LimrunAdbCommandOptions) => { - calls.push({ argv: serializeAndroidAdbInvocation(invocation), options }); + calls.push({ argv: [...serializeAndroidAdbInvocation(invocation)], options }); return ok; }, }, diff --git a/packages/provider-limrun/src/app-log-reconnect.test.ts b/packages/provider-limrun/src/app-log-reconnect.test.ts index 2b3fae9306..5071c2a823 100644 --- a/packages/provider-limrun/src/app-log-reconnect.test.ts +++ b/packages/provider-limrun/src/app-log-reconnect.test.ts @@ -198,7 +198,7 @@ test('addresses app-log adb traffic at the tunnel serial and hands cleanup a com calls.push({ selector: invocation.target.selector, command: [...invocation.command], - argv: serializeAndroidAdbInvocation(invocation), + argv: [...serializeAndroidAdbInvocation(invocation)], options, }); return { exitCode: 0, stdout: '', stderr: '' }; diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index fce2b7d5c5..f2ef1c4c2c 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -307,7 +307,7 @@ function createContractFixture() { }, host: { runAdb: async (invocation: AndroidAdbInvocation) => { - adbCalls.push(serializeAndroidAdbInvocation(invocation)); + adbCalls.push([...serializeAndroidAdbInvocation(invocation)]); return { stdout: '', stderr: '', exitCode: 0 }; }, archiveDirectory: async () => undefined, diff --git a/src/sdk/limrun-runtime-dependencies.test.ts b/src/sdk/limrun-runtime-dependencies.test.ts index 73498587e5..56740b7ba2 100644 --- a/src/sdk/limrun-runtime-dependencies.test.ts +++ b/src/sdk/limrun-runtime-dependencies.test.ts @@ -161,7 +161,7 @@ test('host.runAdb carries addressing apart from payload and routes through the h const result = await withAndroidHostAdbTransport( async (invocation, options) => { seen.push({ - args: serializeAndroidAdbInvocation(invocation), + args: [...serializeAndroidAdbInvocation(invocation)], payload: invocation.command, ...(options ? { options } : {}), });