Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
names: ['--activity'],
type: 'string',
usageLabel: '--activity <component>',
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,
},
Expand Down
18 changes: 18 additions & 0 deletions packages/kernel/src/device-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
assertDeviceShellArgv,
deviceShellArgv,
deviceShellExecutableOf,
relayDeviceShellArgvWithOptions,
relayDeviceShellArgvWithoutOptions,
shellFragment,
} from './device-shell.ts';
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions packages/kernel/src/device-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
}),
);
Expand Down
42 changes: 42 additions & 0 deletions packages/platform-android/src/__tests__/app-lifecycle-open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
84 changes: 62 additions & 22 deletions packages/platform-android/src/adb-provider-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<unknown> | undefined)
| ((cmd: string, args: readonly string[], options: object) => Promise<unknown> | undefined)
| undefined;
bindAndroidAdbHostStub({
withAdbCommandExecutorOverride: async (override, fn) => {
Expand All @@ -350,14 +350,45 @@ 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();
});
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<unknown> | 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 = {
Expand All @@ -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<unknown> | undefined)
| ((cmd: string, args: readonly string[], options: object) => Promise<unknown> | undefined)
| undefined;
bindAndroidAdbHostStub({
withAdbCommandExecutorOverride: async (override, fn) => {
Expand Down Expand Up @@ -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'],
Expand All @@ -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<unknown> | undefined)
| ((cmd: string, args: readonly string[], options: object) => Promise<unknown> | undefined)
| undefined;
bindAndroidAdbHostStub({
execAdb: async (invocation, options) => {
Expand All @@ -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([
Expand Down Expand Up @@ -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<unknown> | undefined)
| ((cmd: string, args: readonly string[], options: object) => Promise<unknown> | undefined)
| undefined;
bindAndroidAdbHostStub({
execAdb: async (invocation) => {
Expand All @@ -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(
Expand Down Expand Up @@ -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<typeof hostCall>[] = [];
let captured:
| ((cmd: string, args: string[], options: object) => Promise<unknown> | undefined)
| ((cmd: string, args: readonly string[], options: object) => Promise<unknown> | undefined)
| undefined;
bindAndroidAdbHostStub({
execAdb: async (invocation, options) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/platform-android/src/adb-provider-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ export async function withAndroidAdbProvider<T>(
function createAndroidCommandExecutorOverride(
scope: AndroidAdbProviderScope,
): AndroidAdbCommandExecutorOverride {
const exec = guardDeviceShell(scope.provider.exec);
Comment thread
thymikee marked this conversation as resolved.
return (cmd, args, options) => {
if (!isAdbCommand(cmd)) return undefined;
if (scope.serverPort === undefined && cmd !== 'adb') return undefined;
Expand All @@ -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;
Expand Down
14 changes: 8 additions & 6 deletions packages/platform-android/src/adb-transport.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Record<string, 'serial' | 'server'>> = {
Expand Down Expand Up @@ -631,7 +633,7 @@ export function lowerAndroidAdbInvocation<Options extends AndroidAdbServerOption
invocation: AndroidAdbInvocation,
options: Options | undefined,
environment: Record<string, string | undefined>,
): { args: string[]; options: Omit<Options, 'serverPort'> } {
): { args: readonly string[]; options: Omit<Options, 'serverPort'> } {
const { serverPort: _requestedServerPort, ...execOptions } = options ?? ({} as Options);
const port = requireAndroidAdbServerPort(invocation, options);
const resolved =
Expand Down
33 changes: 33 additions & 0 deletions packages/platform-android/src/app-control.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
Loading
Loading