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
82 changes: 74 additions & 8 deletions packages/platform-android/src/adb-provider-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import type { DeviceInfo } from '@agent-device/kernel/device';
import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts';
import {
createLocalAndroidAdbProvider,
createDeviceAdbExecutor,
resolveAndroidAdbExecutor,
resolveAndroidAdbProvider,
resolveAndroidTextInjector,
resolveAndroidTouchProvider,
resolveScopedAndroidAdbBackgroundTransport,
withAndroidAdbProvider,
} from './adb-provider-scope.ts';
Expand Down Expand Up @@ -95,7 +97,7 @@ test('the installed override routes only normalized device-scoped adb calls to t
expect(providerCalls).toEqual([['shell', 'ls']]);
});

test('a managed port scope routes host adb and matching serial calls to its private server', async () => {
test('a managed port scope rejects foreign serials before host adb execution', async () => {
const hostCalls: Array<{ args: string[]; serverPort?: number }> = [];
bindAndroidAdbHostStub({
execHostAdb: async (args, options) => {
Expand All @@ -109,16 +111,19 @@ test('a managed port scope routes host adb and matching serial calls to its priv
{ serial: DEVICE.id, serverPort: 15_037 },
async () => {
await runAndroidHostAdb(['devices']);
await runAndroidHostAdb(['shell', 'id'], { env: { ANDROID_SERIAL: OTHER.id } });
await runAndroidHostAdb(['-s', DEVICE.id, 'shell', 'getprop']);
await runAndroidHostAdb(['-s', OTHER.id, 'shell', 'getprop']);
await expect(runAndroidHostAdb(['-s', OTHER.id, 'shell', 'getprop'])).rejects.toMatchObject({
details: { reason: 'managed-device-transport-mismatch' },
});
},
);
await runAndroidHostAdb(['devices']);

expect(hostCalls).toEqual([
{ args: ['devices'], serverPort: 15_037 },
{ args: ['-s', DEVICE.id, 'devices'], serverPort: 15_037 },
{ args: ['-s', DEVICE.id, 'shell', 'id'], serverPort: 15_037 },
{ args: ['-s', DEVICE.id, 'shell', 'getprop'], serverPort: 15_037 },
{ args: ['-s', OTHER.id, 'shell', 'getprop'] },
{ args: ['devices'] },
]);
});
Expand Down Expand Up @@ -155,7 +160,9 @@ test('a managed port scope classifies absolute adb commands and preserves the de
['-s', DEVICE.id, 'shell', 'ls'],
{},
);
expect(captured?.('adb', ['-s', OTHER.id, 'shell', 'ls'], {})).toBeUndefined();
expect(() => captured?.('adb', ['-s', OTHER.id, 'shell', 'ls'], {})).toThrowError(
expect.objectContaining({ details: { reason: 'managed-device-transport-mismatch' } }),
);
expect(captured?.('emulator', ['-list-avds'], {})).toBeUndefined();
expect(global).toBeDefined();
expect(matching).toBeDefined();
Expand All @@ -164,10 +171,69 @@ test('a managed port scope classifies absolute adb commands and preserves the de
},
);

expect(hostCalls).toEqual([['devices', '-l']]);
expect(hostCalls).toEqual([['-s', DEVICE.id, 'devices', '-l']]);
expect(providerCalls).toEqual([['shell', 'ls']]);
});

test('managed port scopes refuse foreign device resolvers before returning a local transport', async () => {
bindAndroidAdbHostStub();
await withAndroidAdbProvider(
{ exec: async () => ok() },
{ serial: DEVICE.id, serverPort: 15_037 },
async () => {
for (const resolve of [
resolveAndroidAdbExecutor,
resolveAndroidAdbProvider,
resolveScopedAndroidAdbBackgroundTransport,
resolveAndroidTextInjector,
resolveAndroidTouchProvider,
]) {
expect(() => resolve(OTHER)).toThrowError(
expect.objectContaining({ details: { reason: 'managed-device-transport-mismatch' } }),
);
}
},
);
});

test('private-port execution contains local transports constructed before entering the scope', async () => {
const calls: Array<{ serial: string; serverPort?: number }> = [];
bindAndroidAdbHostStub({
execSerialAdb: async (serial, _args, options) => {
calls.push({ serial, serverPort: options?.serverPort });
return ok();
},
spawnSerialAdb: (serial, _args, options) => {
calls.push({ serial, serverPort: options?.serverPort });
return undefined as never;
},
});
const matching = createLocalAndroidAdbProvider(DEVICE);
const foreign = createLocalAndroidAdbProvider(OTHER);
const wrongPort = createDeviceAdbExecutor(DEVICE, { serverPort: 15_038 });
await withAndroidAdbProvider(
{ exec: async () => ok() },
{ serial: DEVICE.id, serverPort: 15_037 },
async () => {
await matching.exec(['shell', 'id']);
matching.spawn?.(['logcat']);
await expect(foreign.exec(['shell', 'id'])).rejects.toMatchObject({
details: { reason: 'managed-device-transport-mismatch' },
});
expect(() => foreign.spawn?.(['logcat'])).toThrowError(
expect.objectContaining({ details: { reason: 'managed-device-transport-mismatch' } }),
);
await expect(wrongPort(['shell', 'id'])).rejects.toMatchObject({
details: { reason: 'managed-device-transport-mismatch' },
});
},
);
expect(calls).toEqual([
{ serial: DEVICE.id, serverPort: 15_037 },
{ serial: DEVICE.id, serverPort: 15_037 },
]);
});

test('a managed port scope keeps shell -s arguments on the private transport', async () => {
const hostCalls: Array<{ args: string[]; serverPort?: number }> = [];
let captured:
Expand Down Expand Up @@ -196,8 +262,8 @@ test('a managed port scope keeps shell -s arguments on the private transport', a
);

expect(hostCalls).toEqual([
{ args: ['shell', 'echo', '-s', OTHER.id], serverPort: 15_037 },
{ args: ['shell', 'echo', '-s', OTHER.id], serverPort: 15_037 },
{ args: ['-s', DEVICE.id, 'shell', 'echo', '-s', OTHER.id], serverPort: 15_037 },
{ args: ['-s', DEVICE.id, 'shell', 'echo', '-s', OTHER.id], serverPort: 15_037 },
]);
});

Expand Down
72 changes: 53 additions & 19 deletions packages/platform-android/src/adb-provider-scope.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import path from 'node:path';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import {
requireAndroidAdbHost,
withAndroidHostAdbTransport,
Expand Down Expand Up @@ -42,23 +43,25 @@ export function createDeviceAdbExecutor(
}

function createSerialAdbExecutor(serial: string, serverPort?: number): AndroidAdbExecutor {
return withAdbFailureHints(
async (args, options) =>
await requireAndroidAdbHost().execSerialAdb(
serial,
args,
serverPort === undefined ? options : { ...options, serverPort },
),
);
return withAdbFailureHints(async (args, options) => {
const port = scopedServerPort(serial, serverPort);
return await requireAndroidAdbHost().execSerialAdb(
serial,
args,
port === undefined ? options : { ...options, serverPort: port },
);
});
}

function createSerialAdbSpawner(serial: string, serverPort?: number): AndroidAdbSpawner {
return (args, options) =>
requireAndroidAdbHost().spawnSerialAdb(
return (args, options) => {
const port = scopedServerPort(serial, serverPort);
return requireAndroidAdbHost().spawnSerialAdb(
serial,
args,
serverPort === undefined ? options : { ...options, serverPort },
port === undefined ? options : { ...options, serverPort: port },
);
};
}

export function createLocalAndroidAdbProvider(
Expand All @@ -83,7 +86,7 @@ export function resolveAndroidAdbExecutor(
device: DeviceInfo,
executor?: AndroidAdbExecutor,
): AndroidAdbExecutor {
const scoped = androidAdbProviderScope.getStore();
const scoped = scopeForDevice(device);
if (executor) return executor;
if (scoped?.serial === device.id) return scoped.provider.exec;
return createDeviceAdbExecutor(device);
Expand All @@ -93,8 +96,8 @@ export function resolveAndroidAdbProvider(
device: DeviceInfo,
provider?: AndroidAdbProvider | AndroidAdbExecutor,
): AndroidAdbProvider {
const scoped = scopeForDevice(device);
if (provider) return normalizeAndroidAdbProvider(provider);
const scoped = androidAdbProviderScope.getStore();
return scoped?.serial === device.id
? normalizeAndroidAdbProvider(scoped.provider)
: createLocalAndroidAdbProvider(device);
Expand All @@ -108,7 +111,7 @@ export function resolveAndroidAdbProvider(
export function resolveScopedAndroidAdbBackgroundTransport(
device: DeviceInfo,
): ScopedAndroidAdbBackgroundTransport {
const scoped = androidAdbProviderScope.getStore();
const scoped = scopeForDevice(device);
if (scoped?.serial !== device.id) return { mode: 'local' };
return {
mode: 'transport-composed',
Expand All @@ -117,12 +120,12 @@ export function resolveScopedAndroidAdbBackgroundTransport(
}

export function resolveAndroidTextInjector(device: DeviceInfo): AndroidTextInjector | undefined {
const scoped = androidAdbProviderScope.getStore();
const scoped = scopeForDevice(device);
return scoped?.serial === device.id ? scoped.provider.text : undefined;
}

export function resolveAndroidTouchProvider(device: DeviceInfo): AndroidTouchProvider | undefined {
const scoped = androidAdbProviderScope.getStore();
const scoped = scopeForDevice(device);
return scoped?.serial === device.id && scoped.provider.touch ? scoped.provider : undefined;
}

Expand Down Expand Up @@ -170,6 +173,7 @@ function createAndroidCommandExecutorOverride(
if (!isAdbCommand(cmd)) return undefined;
if (scope.serverPort === undefined && cmd !== 'adb') return undefined;
const serial = readAdbSerial(args);
requireScopedSerial(scope, serial);
if (serial && serial !== scope.serial) return undefined;
if (serial === scope.serial) {
const providerArgs = stripAdbSerialArgs(args, scope.serial);
Expand All @@ -181,7 +185,7 @@ function createAndroidCommandExecutorOverride(
if (scope.serverPort === undefined) return undefined;
return requireAndroidAdbHost().withoutAdbCommandExecutorOverride(
async () =>
await requireAndroidAdbHost().execHostAdb(args, {
await requireAndroidAdbHost().execHostAdb(['-s', scope.serial, ...args], {
...options,
allowFailure: true,
serverPort: scope.serverPort,
Expand All @@ -193,18 +197,48 @@ function createAndroidCommandExecutorOverride(
function createScopedHostTransport(scope: AndroidAdbProviderScope): AndroidAdbHostTransport {
return async (args: string[], options?: AndroidAdbExecutorOptions) => {
const serial = readAdbSerial(args);
requireScopedSerial(scope, serial);
const host = requireAndroidAdbHost();
return await host.withoutAdbCommandExecutorOverride(
async () =>
await host.execHostAdb(args, {
await host.execHostAdb(serial === undefined ? ['-s', scope.serial, ...args] : args, {
...options,
allowFailure: true,
...(serial && serial !== scope.serial ? {} : { serverPort: scope.serverPort }),
serverPort: scope.serverPort,
}),
);
};
}

function scopeForDevice(device: DeviceInfo): AndroidAdbProviderScope | undefined {
const scoped = androidAdbProviderScope.getStore();
requireScopedSerial(scoped, device.id);
return scoped;
}

function requireScopedSerial(
scope: AndroidAdbProviderScope | undefined,
serial: string | undefined,
) {
if (scope?.serverPort !== undefined && serial !== undefined && serial !== scope.serial) {
throw new AppError('COMMAND_FAILED', 'Managed ADB transport cannot address another device.', {
reason: 'managed-device-transport-mismatch',
});
}
}

function scopedServerPort(serial: string, requested: number | undefined): number | undefined {
const scope = androidAdbProviderScope.getStore();
requireScopedSerial(scope, serial);
if (scope?.serverPort === undefined) return requested;
if (requested !== undefined && requested !== scope.serverPort) {
throw new AppError('COMMAND_FAILED', 'Managed ADB transport cannot select another server.', {
reason: 'managed-device-transport-mismatch',
});
}
return scope.serverPort;
}

function isAdbCommand(command: string): boolean {
const executable = path.basename(command).replace(/\.(?:com|exe|bat|cmd)$/i, '');
return executable === 'adb';
Expand Down
7 changes: 5 additions & 2 deletions src/managed-device-reachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,11 @@ test.skipIf(process.platform === 'win32')(
booted: true,
},
],
host: { args: ['-P', '15037', 'devices'], port: '15037' },
hostWithWrongPort: { args: ['-P', '15037', 'devices'], port: '15037' },
host: { args: ['-P', '15037', '-s', 'emulator-15037', 'devices'], port: '15037' },
hostWithWrongPort: {
args: ['-P', '15037', '-s', 'emulator-15037', 'devices'],
port: '15037',
},
serial: {
args: ['-P', '15037', '-s', 'emulator-15037', 'shell', 'id'],
port: '15037',
Expand Down
Loading
Loading