From f9ee5d97214000986996b1e9b7e4c40c9c88659f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 4 Sep 2026 23:14:34 +0200 Subject: [PATCH] feat(runtime): route managed leases through contained transports --- .../src/__tests__/adb-executor.test.ts | 31 +++ .../src/adb-provider-scope.test.ts | 191 +++++++++++++++++ .../src/adb-provider-scope.ts | 132 ++++++++++-- .../platform-android/src/adb-transport.ts | 4 +- src/managed-device-reachability.test.ts | 201 ++++++++++++++++++ src/managed-device-reachability.ts | 147 +++++++++++++ src/platform-runtime-android-adb-host.test.ts | 64 +++++- src/platform-runtime-android-adb-host.ts | 70 +++++- 8 files changed, 807 insertions(+), 33 deletions(-) create mode 100644 src/managed-device-reachability.test.ts create mode 100644 src/managed-device-reachability.ts diff --git a/packages/platform-android/src/__tests__/adb-executor.test.ts b/packages/platform-android/src/__tests__/adb-executor.test.ts index a914a39c0e..6aa5d5b3cf 100644 --- a/packages/platform-android/src/__tests__/adb-executor.test.ts +++ b/packages/platform-android/src/__tests__/adb-executor.test.ts @@ -231,6 +231,37 @@ test('createLocalAndroidAdbProvider exposes local pull and install capabilities' ]); }); +test('createLocalAndroidAdbProvider carries a private server port through every adb capability', async () => { + mockRunCmd.mockClear(); + mockRunCmdBackground.mockClear(); + const provider = createLocalAndroidAdbProvider( + { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }, + { serverPort: 15_037 }, + ); + + await provider.exec(['shell', 'echo', 'ok']); + provider.spawn?.(['logcat']); + await provider.reverse?.ensure({ local: 'tcp:8081', remote: 'tcp:8081' }); + await provider.pull?.('/sdcard/video.mp4', '/tmp/video.mp4'); + await provider.install?.('/tmp/app.apk'); + + assert.equal(readServerPort(mockRunCmdBackground.mock.calls[0]?.[2]), 15_037); + assert.equal(mockRunCmd.mock.calls.length, 4); + for (const call of mockRunCmd.mock.calls) assert.equal(readServerPort(call[2]), 15_037); +}); + +function readServerPort(options: unknown): number | undefined { + if (options === null || typeof options !== 'object') return undefined; + const value = (options as { serverPort?: unknown }).serverPort; + return typeof value === 'number' ? value : undefined; +} + test('createAndroidPortReverseManager makes duplicate setup idempotent and cleans owner mappings', async () => { const calls: string[][] = []; const manager = createAndroidPortReverseManager(async (args) => { diff --git a/packages/platform-android/src/adb-provider-scope.test.ts b/packages/platform-android/src/adb-provider-scope.test.ts index 657e863964..af4eb66b20 100644 --- a/packages/platform-android/src/adb-provider-scope.test.ts +++ b/packages/platform-android/src/adb-provider-scope.test.ts @@ -2,12 +2,14 @@ import { expect, test } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts'; import { + createLocalAndroidAdbProvider, resolveAndroidAdbExecutor, resolveAndroidAdbProvider, resolveAndroidTextInjector, resolveScopedAndroidAdbBackgroundTransport, withAndroidAdbProvider, } from './adb-provider-scope.ts'; +import { runAndroidHostAdb } from './adb-host.ts'; import type { AndroidAdbExecutorResult, AndroidAdbProvider } from './adb-transport.ts'; const DEVICE: DeviceInfo = { @@ -92,3 +94,192 @@ 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 () => { + const hostCalls: Array<{ args: string[]; serverPort?: number }> = []; + bindAndroidAdbHostStub({ + execHostAdb: async (args, options) => { + hostCalls.push({ args, serverPort: options?.serverPort }); + return ok(); + }, + }); + + await withAndroidAdbProvider( + { exec: async () => ok() }, + { serial: DEVICE.id, serverPort: 15_037 }, + async () => { + await runAndroidHostAdb(['devices']); + await runAndroidHostAdb(['-s', DEVICE.id, 'shell', 'getprop']); + await runAndroidHostAdb(['-s', OTHER.id, 'shell', 'getprop']); + }, + ); + await runAndroidHostAdb(['devices']); + + expect(hostCalls).toEqual([ + { args: ['devices'], serverPort: 15_037 }, + { args: ['-s', DEVICE.id, 'shell', 'getprop'], serverPort: 15_037 }, + { args: ['-s', OTHER.id, 'shell', 'getprop'] }, + { args: ['devices'] }, + ]); +}); + +test('a managed port scope classifies absolute adb commands and preserves the default boundary', async () => { + const providerCalls: string[][] = []; + const hostCalls: string[][] = []; + let captured: + | ((cmd: string, args: string[], options: object) => Promise | undefined) + | undefined; + bindAndroidAdbHostStub({ + execHostAdb: async (args) => { + hostCalls.push(args); + return ok(); + }, + withAdbCommandExecutorOverride: async (override, fn) => { + captured = override; + return await fn(); + }, + }); + + await withAndroidAdbProvider( + { + exec: async (args) => { + providerCalls.push(args); + return ok(); + }, + }, + { serial: DEVICE.id, serverPort: 15_037 }, + async () => { + 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'], + {}, + ); + expect(captured?.('adb', ['-s', OTHER.id, 'shell', 'ls'], {})).toBeUndefined(); + expect(captured?.('emulator', ['-list-avds'], {})).toBeUndefined(); + expect(global).toBeDefined(); + expect(matching).toBeDefined(); + await global; + await matching; + }, + ); + + expect(hostCalls).toEqual([['devices', '-l']]); + expect(providerCalls).toEqual([['shell', 'ls']]); +}); + +test('a managed port scope keeps shell -s arguments on the private transport', async () => { + const hostCalls: Array<{ args: string[]; serverPort?: number }> = []; + let captured: + | ((cmd: string, args: string[], options: object) => Promise | undefined) + | undefined; + bindAndroidAdbHostStub({ + execHostAdb: async (args, options) => { + hostCalls.push({ args, serverPort: options?.serverPort }); + return ok(); + }, + withAdbCommandExecutorOverride: async (override, fn) => { + captured = override; + return await fn(); + }, + }); + + await withAndroidAdbProvider( + { exec: async () => ok() }, + { serial: DEVICE.id, serverPort: 15_037 }, + async () => { + await runAndroidHostAdb(['shell', 'echo', '-s', OTHER.id]); + const shellCommand = captured?.('adb', ['shell', 'echo', '-s', OTHER.id], {}); + expect(shellCommand).toBeDefined(); + await shellCommand; + }, + ); + + expect(hostCalls).toEqual([ + { args: ['shell', 'echo', '-s', OTHER.id], serverPort: 15_037 }, + { args: ['shell', 'echo', '-s', OTHER.id], serverPort: 15_037 }, + ]); +}); + +test('a managed port scope restores the default transport after task failure', async () => { + const ports: Array = []; + bindAndroidAdbHostStub({ + execHostAdb: async (_args, options) => { + ports.push(options?.serverPort); + return ok(); + }, + }); + + await expect( + withAndroidAdbProvider( + { exec: async () => ok() }, + { serial: DEVICE.id, serverPort: 15_037 }, + async () => { + await runAndroidHostAdb(['devices']); + throw new Error('stop managed request'); + }, + ), + ).rejects.toThrow('stop managed request'); + await runAndroidHostAdb(['devices']); + + expect(ports).toEqual([15_037, undefined]); +}); + +test('a managed port scope carries its server through the local background transport', async () => { + const spawnCalls: Array<{ serial: string; args: string[]; serverPort?: number }> = []; + bindAndroidAdbHostStub({ + spawnSerialAdb: (serial, args, options) => { + spawnCalls.push({ serial, args, serverPort: options?.serverPort }); + return undefined as never; + }, + }); + const deviceProvider = createLocalAndroidAdbProvider(DEVICE, { serverPort: 15_037 }); + + await withAndroidAdbProvider( + deviceProvider, + { serial: DEVICE.id, serverPort: 15_037 }, + async () => { + const transport = resolveScopedAndroidAdbBackgroundTransport(DEVICE); + expect(transport.mode).toBe('transport-composed'); + if (transport.mode === 'transport-composed') { + transport.spawn?.(['logcat', '-v', 'threadtime']); + } + }, + ); + + expect(spawnCalls).toEqual([ + { serial: DEVICE.id, args: ['logcat', '-v', 'threadtime'], serverPort: 15_037 }, + ]); +}); + +test('managed port scopes remain isolated across concurrent requests', async () => { + const hostCalls: Array<{ serial: string; serverPort?: number }> = []; + bindAndroidAdbHostStub({ + execHostAdb: async (args, options) => { + hostCalls.push({ + serial: args[args.indexOf('-s') + 1] ?? 'global', + serverPort: options?.serverPort, + }); + await Promise.resolve(); + return ok(); + }, + }); + + await Promise.all([ + withAndroidAdbProvider( + { exec: async () => ok() }, + { serial: DEVICE.id, serverPort: 15_037 }, + async () => await runAndroidHostAdb(['-s', DEVICE.id, 'shell', 'id']), + ), + withAndroidAdbProvider( + { exec: async () => ok() }, + { serial: OTHER.id, serverPort: 15_038 }, + async () => await runAndroidHostAdb(['-s', OTHER.id, 'shell', 'id']), + ), + ]); + + expect(hostCalls).toEqual([ + { serial: DEVICE.id, serverPort: 15_037 }, + { serial: OTHER.id, serverPort: 15_038 }, + ]); +}); diff --git a/packages/platform-android/src/adb-provider-scope.ts b/packages/platform-android/src/adb-provider-scope.ts index b6840b070a..7602699438 100644 --- a/packages/platform-android/src/adb-provider-scope.ts +++ b/packages/platform-android/src/adb-provider-scope.ts @@ -1,12 +1,19 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + requireAndroidAdbHost, + withAndroidHostAdbTransport, + type AndroidAdbCommandExecutorOverride, + type AndroidAdbHostTransport, +} from './adb-host.ts'; import { withAdbFailureHints } from './adb-failure.ts'; -import { requireAndroidAdbHost, type AndroidAdbCommandExecutorOverride } from './adb-host.ts'; import { createExecAndroidPortReverseProvider } from './adb-port-reverse.ts'; import { normalizeAndroidAdbProvider } from './adb-provider-normalization.ts'; import { normalizeAndroidAdbInstallOptions, type AndroidAdbExecutor, + type AndroidAdbExecutorOptions, type AndroidAdbProvider, type AndroidAdbProviderScopeOptions, type AndroidAdbSpawner, @@ -22,29 +29,46 @@ import { type AndroidAdbProviderScope = { provider: AndroidAdbProvider; serial: string; + serverPort?: number; }; const androidAdbProviderScope = new AsyncLocalStorage(); -export function createDeviceAdbExecutor(device: DeviceInfo): AndroidAdbExecutor { - return createSerialAdbExecutor(device.id); +export function createDeviceAdbExecutor( + device: DeviceInfo, + options: Readonly<{ serverPort?: number }> = {}, +): AndroidAdbExecutor { + return createSerialAdbExecutor(device.id, options.serverPort); } -function createSerialAdbExecutor(serial: string): AndroidAdbExecutor { +function createSerialAdbExecutor(serial: string, serverPort?: number): AndroidAdbExecutor { return withAdbFailureHints( - async (args, options) => await requireAndroidAdbHost().execSerialAdb(serial, args, options), + async (args, options) => + await requireAndroidAdbHost().execSerialAdb( + serial, + args, + serverPort === undefined ? options : { ...options, serverPort }, + ), ); } -function createSerialAdbSpawner(serial: string): AndroidAdbSpawner { - return (args, options) => requireAndroidAdbHost().spawnSerialAdb(serial, args, options); +function createSerialAdbSpawner(serial: string, serverPort?: number): AndroidAdbSpawner { + return (args, options) => + requireAndroidAdbHost().spawnSerialAdb( + serial, + args, + serverPort === undefined ? options : { ...options, serverPort }, + ); } -export function createLocalAndroidAdbProvider(device: DeviceInfo): AndroidAdbProvider { - const exec = createDeviceAdbExecutor(device); +export function createLocalAndroidAdbProvider( + device: DeviceInfo, + options: Readonly<{ serverPort?: number }> = {}, +): AndroidAdbProvider { + const exec = createDeviceAdbExecutor(device, options); return { exec, - spawn: createSerialAdbSpawner(device.id), + spawn: createSerialAdbSpawner(device.id, options.serverPort), reverse: createExecAndroidPortReverseProvider(exec), pull: async (remotePath, localPath, options) => await exec(['pull', remotePath, localPath], options), @@ -124,33 +148,97 @@ export async function withAndroidAdbProvider( // command-executor override and direct resolveAndroidAdb* lookups — gets // classified failure hints on exec and the semantic provider methods alike. const enriched = normalizeAndroidAdbProvider(provider); - const scope = { provider: enriched, serial: options.serial }; + const scope = { + provider: enriched, + serial: options.serial, + ...(options.serverPort === undefined ? {} : { serverPort: options.serverPort }), + }; const override = createAndroidCommandExecutorOverride(scope); - return await androidAdbProviderScope.run( - scope, - async () => await requireAndroidAdbHost().withAdbCommandExecutorOverride(override, fn), - ); + const run = async () => + await androidAdbProviderScope.run( + scope, + async () => await requireAndroidAdbHost().withAdbCommandExecutorOverride(override, fn), + ); + if (options.serverPort === undefined) return await run(); + return await withAndroidHostAdbTransport(createScopedHostTransport(scope), run); } function createAndroidCommandExecutorOverride( scope: AndroidAdbProviderScope, ): AndroidAdbCommandExecutorOverride { return (cmd, args, options) => { - if (cmd !== 'adb') return undefined; - const providerArgs = stripAdbSerialArgs(args, scope.serial); - if (!providerArgs) return undefined; + if (!isAdbCommand(cmd)) return undefined; + if (scope.serverPort === undefined && cmd !== 'adb') return undefined; + const serial = readAdbSerial(args); + if (serial && serial !== scope.serial) return undefined; + if (serial === scope.serial) { + const providerArgs = stripAdbSerialArgs(args, scope.serial); + if (!providerArgs) return undefined; + return requireAndroidAdbHost().withoutAdbCommandExecutorOverride( + async () => await scope.provider.exec(providerArgs, options), + ); + } + if (scope.serverPort === undefined) return undefined; return requireAndroidAdbHost().withoutAdbCommandExecutorOverride( - async () => await scope.provider.exec(providerArgs, options), + async () => + await requireAndroidAdbHost().execHostAdb(args, { + ...options, + allowFailure: true, + serverPort: scope.serverPort, + }), + ); + }; +} + +function createScopedHostTransport(scope: AndroidAdbProviderScope): AndroidAdbHostTransport { + return async (args: string[], options?: AndroidAdbExecutorOptions) => { + const serial = readAdbSerial(args); + const host = requireAndroidAdbHost(); + return await host.withoutAdbCommandExecutorOverride( + async () => + await host.execHostAdb(args, { + ...options, + allowFailure: true, + ...(serial && serial !== scope.serial ? {} : { serverPort: scope.serverPort }), + }), ); }; } +function isAdbCommand(command: string): boolean { + const executable = path.basename(command).replace(/\.(?:com|exe|bat|cmd)$/i, ''); + return executable === 'adb'; +} + +function readAdbSerial(args: readonly string[]): string | undefined { + const serialIndex = findAdbSerialIndex(args); + return serialIndex === undefined ? undefined : args[serialIndex + 1]; +} + +function findAdbSerialIndex(args: readonly string[]): number | undefined { + let index = 0; + while (index < args.length) { + const argument = args[index]; + if (argument === '-s') return index; + if (argument === '-P' || argument === '-H' || argument === '-L') { + index += 2; + continue; + } + if (argument === '-a' || argument === '-d' || argument === '-e') { + index += 1; + continue; + } + return undefined; + } + return undefined; +} + function stripAdbSerialArgs(args: string[], expectedSerial: string): string[] | undefined { // The provider scope only owns normalized device-scoped adb calls: // adb -s . Global commands // such as adb devices/version, calls for another serial, and host-preconfigured // invocations stay local. - if (args[0] !== '-s' || !args[1]) return undefined; - if (args[1] !== expectedSerial) return undefined; - return args.slice(2); + const serialIndex = findAdbSerialIndex(args); + if (serialIndex === undefined || args[serialIndex + 1] !== expectedSerial) return undefined; + return [...args.slice(0, serialIndex), ...args.slice(serialIndex + 2)]; } diff --git a/packages/platform-android/src/adb-transport.ts b/packages/platform-android/src/adb-transport.ts index 63227914be..8bb68b98f0 100644 --- a/packages/platform-android/src/adb-transport.ts +++ b/packages/platform-android/src/adb-transport.ts @@ -16,6 +16,8 @@ export type AndroidAdbExecutorOptions = { binaryStdout?: boolean; stdin?: string | Buffer; signal?: AbortSignal; + env?: Record; + serverPort?: number; }; export type AndroidAdbExecutorResult = { @@ -30,7 +32,6 @@ type AndroidAdbStdioOption = 'overlapped' | 'pipe' | 'ignore' | 'inherit'; export type AndroidAdbSpawnOptions = AndroidAdbExecutorOptions & { cwd?: string; - env?: Record; detached?: boolean; /** Max stdout/stderr bytes for synchronous runs (default Node ~1MB). */ maxBuffer?: number; @@ -184,6 +185,7 @@ export type AndroidAdbProvider = AndroidAdbProviderBase & AndroidTouchCapabiliti export type AndroidAdbProviderScopeOptions = { serial: string; + serverPort?: number; }; export type ScopedAndroidAdbBackgroundTransport = diff --git a/src/managed-device-reachability.test.ts b/src/managed-device-reachability.test.ts new file mode 100644 index 0000000000..e35b253d58 --- /dev/null +++ b/src/managed-device-reachability.test.ts @@ -0,0 +1,201 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import type { ManagedLease } from '@agent-device/contracts/managed-device-allocation'; +import type { + DeviceInventoryHostFor, + PlatformRequestScope, +} from '@agent-device/contracts/platform-runtime-host'; +import { createAndroidInventoryModule } from '@agent-device/platform-android'; +import { + resolveAndroidAdbProvider, + runAndroidHostAdb, +} from '@agent-device/platform-android/mechanics'; +import { runCmd } from '@agent-device/host-kit/command'; +import { mkdtempForTestSync } from './__tests__/test-utils/tmp-dir.ts'; +import { + createManagedLeaseReachability, + readManagedLeaseEnvironment, +} from './managed-device-reachability.ts'; +import './platform-runtime-android-adb-host.ts'; + +const iosLease: ManagedLease = { + id: 'lease-ios', + ttlDeadline: 1_000, + device: { address: 'ios-managed-1' }, + environment: { SIMLOCK_IOS_DEVICE_SET: '/private/simlock/sets/ios' }, +}; + +const androidLease: ManagedLease = { + id: 'lease-android', + ttlDeadline: 1_000, + device: { address: 'emulator-15037' }, + environment: { ANDROID_ADB_SERVER_PORT: '15037' }, +}; + +test('reads the exact managed lease environment contract and ignores unrelated keys', () => { + expect( + readManagedLeaseEnvironment('ios', { + SIMLOCK_IOS_DEVICE_SET: ' /private/simlock/sets/ios ', + unrelated: 'preserved', + }), + ).toEqual({ platform: 'ios', deviceSetPath: '/private/simlock/sets/ios' }); + expect( + readManagedLeaseEnvironment('android', { + ANDROID_ADB_SERVER_PORT: '15037', + unrelated: 'preserved', + }), + ).toEqual({ platform: 'android', adbServerPort: 15_037 }); +}); + +test('rejects missing and malformed managed lease environment values', () => { + expect(readManagedLeaseEnvironment('ios', {})).toEqual({ + code: 'LEASE_ENVIRONMENT_INVALID', + platform: 'ios', + key: 'SIMLOCK_IOS_DEVICE_SET', + }); + for (const value of ['', '0', '65536', '15.037', 'port']) { + expect(readManagedLeaseEnvironment('android', { ANDROID_ADB_SERVER_PORT: value })).toEqual({ + code: 'LEASE_ENVIRONMENT_INVALID', + platform: 'android', + key: 'ANDROID_ADB_SERVER_PORT', + }); + } +}); + +test('creates an iOS managed device with the allocator-owned simulator set', async () => { + const reachability = createManagedLeaseReachability({ platform: 'ios', lease: iosLease }); + + expect(reachability.device).toEqual({ + platform: 'apple', + appleOs: 'ios', + id: 'ios-managed-1', + name: 'ios-managed-1', + kind: 'simulator', + target: 'mobile', + simulatorSetPath: '/private/simlock/sets/ios', + }); + await expect(reachability.run(async () => 'ios-result')).resolves.toBe('ios-result'); +}); + +test.skipIf(process.platform === 'win32')( + 'runs Android managed adb mechanics through the lease port without changing process env', + async () => { + const tmpDir = mkdtempForTestSync('agent-device-managed-adb-'); + const adbPath = path.join(tmpDir, 'adb'); + fs.writeFileSync( + adbPath, + [ + '#!/usr/bin/env node', + 'const args = process.argv.slice(2);', + 'const output = args.includes("devices") && args.includes("-l")', + String.raw` ? "List of devices attached\nemulator-15037 device model:Managed_Pixel\n"`, + ' : args.includes("ro.boot.qemu.avd_name")', + String.raw` ? "Managed_Pixel\n"`, + ' : args.includes("sys.boot_completed")', + String.raw` ? "1\n"`, + ' : args.includes("ro.build.characteristics")', + String.raw` ? "phone\n"`, + ' : args.includes("has-feature") || args.includes("pm")', + String.raw` ? "false\n"`, + ' : JSON.stringify({args, port: process.env.ANDROID_ADB_SERVER_PORT ?? null});', + 'process.stdout.write(output);', + '', + ].join('\n'), + ); + fs.chmodSync(adbPath, 0o755); + const previousPath = process.env.PATH; + const previousPort = process.env.ANDROID_ADB_SERVER_PORT; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + try { + const reachability = createManagedLeaseReachability({ + platform: 'android', + lease: androidLease, + }); + const results = await reachability.run(async () => { + const inventoryHost: DeviceInventoryHostFor<'android'> = { + commands: { + which: async () => 'adb', + run: async (request, signal) => + await runCmd(request.executable, [...request.args], { + allowFailure: request.allowFailure, + signal, + timeoutMs: request.timeoutMs, + }), + }, + files: { + isExecutable: async () => false, + createTemporaryTextFile: async () => { + throw new Error('unused'); + }, + }, + homeDirectory: tmpDir, + toolchains: { prepare: async () => {} }, + }; + const inventory = await ( + await createAndroidInventoryModule({ sdkRoots: [tmpDir] }).loadInventory(inventoryHost) + ).discover( + { androidSerialAllowlist: [reachability.device.id], androidAvdSelection: 'running-only' }, + { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + } satisfies PlatformRequestScope, + ); + const host = await runAndroidHostAdb(['devices']); + const hostWithWrongPort = await runAndroidHostAdb(['-P', '9999', 'devices']); + const provider = resolveAndroidAdbProvider(reachability.device); + const serial = await provider.exec(['shell', 'id']); + return { + inventory, + host: JSON.parse(host.stdout), + hostWithWrongPort: JSON.parse(hostWithWrongPort.stdout), + serial: JSON.parse(serial.stdout), + }; + }); + const outside = JSON.parse((await runAndroidHostAdb(['devices'])).stdout); + + expect(results).toEqual({ + inventory: [ + { + platform: 'android', + id: 'emulator-15037', + name: 'Managed Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, + }, + ], + host: { args: ['-P', '15037', 'devices'], port: '15037' }, + hostWithWrongPort: { args: ['-P', '15037', 'devices'], port: '15037' }, + serial: { + args: ['-P', '15037', '-s', 'emulator-15037', 'shell', 'id'], + port: '15037', + }, + }); + expect(outside).toEqual({ args: ['devices'], port: previousPort ?? null }); + expect(process.env.ANDROID_ADB_SERVER_PORT).toBe(previousPort); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + }, +); + +test('fails before Android mechanics load when a managed lease environment is invalid', () => { + expect(() => + createManagedLeaseReachability({ + platform: 'android', + lease: { ...androidLease, environment: { ANDROID_ADB_SERVER_PORT: 'not-a-port' } }, + }), + ).toThrowError( + expect.objectContaining({ + code: 'COMMAND_FAILED', + details: expect.objectContaining({ + reason: 'managed_lease_environment_invalid', + key: 'ANDROID_ADB_SERVER_PORT', + retriable: false, + }), + }), + ); +}); diff --git a/src/managed-device-reachability.ts b/src/managed-device-reachability.ts new file mode 100644 index 0000000000..b7c518cd8a --- /dev/null +++ b/src/managed-device-reachability.ts @@ -0,0 +1,147 @@ +import type { + LeaseEnvironmentError, + ManagedLease, + ManagedLeaseEnvironment, + ManagedLeaseEnvironmentKey, + ManagedLeasePlatform, +} from '@agent-device/contracts/managed-device-allocation'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +export type ManagedLeaseReachabilityInput = Readonly<{ + platform: ManagedLeasePlatform; + lease: ManagedLease; +}>; + +export type ManagedLeaseReachability = Readonly<{ + platform: ManagedLeasePlatform; + lease: ManagedLease; + environment: ManagedLeaseEnvironment; + device: DeviceInfo; + run(task: () => Promise): Promise; +}>; + +export function readManagedLeaseEnvironment( + platform: ManagedLeasePlatform, + environment: Readonly>, +): ManagedLeaseEnvironment | LeaseEnvironmentError { + if (platform === 'ios') { + const deviceSetPath = environment.SIMLOCK_IOS_DEVICE_SET?.trim(); + return deviceSetPath + ? Object.freeze({ platform, deviceSetPath }) + : invalidEnvironment(platform, 'SIMLOCK_IOS_DEVICE_SET'); + } + + const rawPort = environment.ANDROID_ADB_SERVER_PORT?.trim(); + const adbServerPort = rawPort && /^\d+$/.test(rawPort) ? Number(rawPort) : Number.NaN; + return Number.isInteger(adbServerPort) && adbServerPort >= 1 && adbServerPort <= 65_535 + ? Object.freeze({ platform, adbServerPort }) + : invalidEnvironment(platform, 'ANDROID_ADB_SERVER_PORT'); +} + +export function createManagedLeaseReachability( + input: ManagedLeaseReachabilityInput, +): ManagedLeaseReachability { + const environment = readManagedLeaseEnvironment(input.platform, input.lease.environment); + if (isLeaseEnvironmentError(environment)) throw invalidEnvironmentError(environment); + + const address = input.lease.device.address; + if (!address.trim()) { + throw new AppError('COMMAND_FAILED', `Managed ${input.platform} lease has no device address.`, { + reason: 'managed_lease_device_invalid', + platform: input.platform, + retriable: false, + }); + } + + if (input.platform === 'ios') { + if (environment.platform !== 'ios') throw new TypeError('Managed lease environment mismatch'); + const device = createManagedDevice(input.platform, address, environment); + return Object.freeze({ + platform: input.platform, + lease: input.lease, + environment, + device, + run: async (task: () => Promise) => await task(), + }); + } + + if (environment.platform !== 'android') throw new TypeError('Managed lease environment mismatch'); + const device = createManagedDevice(input.platform, address, environment); + let androidProvider: + | Promise + | undefined; + const run = async (task: () => Promise) => { + androidProvider ??= import('@agent-device/platform-android/mechanics').then( + ({ createLocalAndroidAdbProvider }) => + createLocalAndroidAdbProvider(device, { serverPort: environment.adbServerPort }), + ); + const { withAndroidAdbProvider } = await import('@agent-device/platform-android/mechanics'); + return await withAndroidAdbProvider( + await androidProvider, + { serial: device.id, serverPort: environment.adbServerPort }, + task, + ); + }; + + return Object.freeze({ + platform: input.platform, + lease: input.lease, + environment, + device, + run, + }); +} + +function createManagedDevice( + platform: ManagedLeasePlatform, + address: string, + environment: ManagedLeaseEnvironment, +): DeviceInfo { + if (platform === 'ios') { + if (environment.platform !== 'ios') throw new TypeError('Managed lease environment mismatch'); + return Object.freeze({ + platform: 'apple', + appleOs: 'ios', + id: address, + name: address, + kind: 'simulator', + target: 'mobile', + simulatorSetPath: environment.deviceSetPath, + }); + } + if (environment.platform !== 'android') throw new TypeError('Managed lease environment mismatch'); + return Object.freeze({ + platform: 'android', + id: address, + name: address, + kind: 'emulator', + target: 'mobile', + }); +} + +function invalidEnvironment( + platform: ManagedLeasePlatform, + key: ManagedLeaseEnvironmentKey, +): LeaseEnvironmentError { + return Object.freeze({ code: 'LEASE_ENVIRONMENT_INVALID', platform, key }); +} + +function isLeaseEnvironmentError( + value: ManagedLeaseEnvironment | LeaseEnvironmentError, +): value is LeaseEnvironmentError { + return 'code' in value; +} + +function invalidEnvironmentError(error: LeaseEnvironmentError): AppError { + return new AppError( + 'COMMAND_FAILED', + `Managed ${error.platform} lease environment is missing or invalid.`, + { + reason: 'managed_lease_environment_invalid', + platform: error.platform, + key: error.key, + retriable: false, + }, + ); +} diff --git a/src/platform-runtime-android-adb-host.test.ts b/src/platform-runtime-android-adb-host.test.ts index bb548ee403..b56390130c 100644 --- a/src/platform-runtime-android-adb-host.test.ts +++ b/src/platform-runtime-android-adb-host.test.ts @@ -3,7 +3,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { runAndroidHostAdb } from '@agent-device/platform-android/mechanics'; +import { + createDeviceAdbExecutor, + runAndroidHostAdb, +} from '@agent-device/platform-android/mechanics'; import { mkdtempForTestSync } from './__tests__/test-utils/tmp-dir.ts'; import './platform-runtime-android-adb-host.ts'; @@ -38,3 +41,62 @@ test.skipIf(process.platform === 'win32')( } }, ); + +test.skipIf(process.platform === 'win32')( + 'the root host lowers request-local adb server ports without mutating process env', + async () => { + const tmpDir = mkdtempForTestSync('agent-device-adb-server-port-'); + const adbPath = path.join(tmpDir, 'adb'); + fs.writeFileSync( + adbPath, + '#!/usr/bin/env node\n' + + 'process.stdout.write(JSON.stringify({args: process.argv.slice(2), port: process.env.ANDROID_ADB_SERVER_PORT ?? null}));\n', + ); + fs.chmodSync(adbPath, 0o755); + const previousPath = process.env.PATH; + const previousPort = process.env.ANDROID_ADB_SERVER_PORT; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + try { + const adb = createDeviceAdbExecutor( + { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }, + { serverPort: 15_037 }, + ); + const serial = JSON.parse((await adb(['shell', 'id'])).stdout) as { + args: string[]; + port: string | null; + }; + const serialWithWrongPort = JSON.parse((await adb(['-P', '9999', 'shell', 'id'])).stdout) as { + args: string[]; + port: string | null; + }; + const serialWithWrongEnvironment = JSON.parse( + ( + await adb(['shell', 'id'], { + env: { ANDROID_ADB_SERVER_PORT: '9999' }, + }) + ).stdout, + ) as { args: string[]; port: string | null }; + const host = JSON.parse( + (await runAndroidHostAdb(['-P', '9999', 'devices'], { serverPort: 15_038 })).stdout, + ) as { args: string[]; port: string | null }; + + assert.deepEqual(serial, { + args: ['-P', '15037', '-s', 'emulator-5554', 'shell', 'id'], + port: '15037', + }); + assert.deepEqual(serialWithWrongPort, serial); + assert.deepEqual(serialWithWrongEnvironment, serial); + assert.deepEqual(host, { args: ['-P', '15038', 'devices'], port: '15038' }); + assert.equal(process.env.ANDROID_ADB_SERVER_PORT, previousPort); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + }, +); diff --git a/src/platform-runtime-android-adb-host.ts b/src/platform-runtime-android-adb-host.ts index 131c9e7b05..b80ef02a5f 100644 --- a/src/platform-runtime-android-adb-host.ts +++ b/src/platform-runtime-android-adb-host.ts @@ -1,4 +1,5 @@ import { bindAndroidAdbHost } from '@agent-device/platform-android/adb-host'; +import type { AndroidAdbExecutorOptions } from '@agent-device/platform-android/mechanics'; import { createHash, randomUUID } from 'node:crypto'; import { coerceExecResult, @@ -25,8 +26,10 @@ import { import os from 'node:os'; import path from 'node:path'; +const environment = process.env; + bindAndroidAdbHost({ - environment: process.env, + environment, files: { access: async (candidate) => await access(candidate), ensureDirectory: async (directory) => { @@ -78,24 +81,30 @@ bindAndroidAdbHost({ }, writeBytes: async (filePath, value) => await writeFile(filePath, value), }, - execSerialAdb: async (serial, args, options) => - await withoutCommandExecutorOverride( + execSerialAdb: async (serial, args, options) => { + const invocation = adbInvocation(['-s', serial, ...args], options); + return await withoutCommandExecutorOverride( async () => - await runCmd('adb', ['-s', serial, ...args], { - ...options, + await runCmd('adb', invocation.args, { + ...invocation.options, detached: process.platform !== 'win32', }), - ), + ); + }, spawnSerialAdb: (serial, args, options) => { - const background = runCmdBackground('adb', ['-s', serial, ...args], { - ...options, + const invocation = adbInvocation(['-s', serial, ...args], options); + const background = runCmdBackground('adb', invocation.args, { + ...invocation.options, allowFailure: true, captureOutput: false, }); void background.wait.catch(() => {}); return background.child; }, - execHostAdb: async (args, options) => await runCmd('adb', args, options), + execHostAdb: async (args, options) => { + const invocation = adbInvocation(args, options); + return await runCmd('adb', invocation.args, invocation.options); + }, withAdbCommandExecutorOverride: withCommandExecutorOverride, withoutAdbCommandExecutorOverride: withoutCommandExecutorOverride, coerceAdbResult: coerceExecResult, @@ -129,3 +138,46 @@ bindAndroidAdbHost({ return await makeEnsureAndroidHelperInstalled(config)(request); }, }); + +function adbInvocation( + args: string[], + options?: Options, +): { args: string[]; options: Omit } { + const { serverPort, ...withoutServerPort } = options ?? ({} as Options); + if (serverPort === undefined) return { args, options: withoutServerPort }; + return { + args: withServerPort(args, serverPort), + options: { + ...withoutServerPort, + env: { + ...environment, + ...(withoutServerPort.env ?? {}), + ANDROID_ADB_SERVER_PORT: String(serverPort), + }, + }, + }; +} + +function withServerPort(args: string[], serverPort: number): string[] { + const normalized = ['-P', String(serverPort)]; + let index = 0; + while (index < args.length) { + const argument = args[index]; + if (argument === '-P') { + index += 2; + continue; + } + if (argument === '-s' || argument === '-H' || argument === '-L') { + normalized.push(argument, args[index + 1]!); + index += 2; + continue; + } + if (argument === '-a' || argument === '-d' || argument === '-e') { + normalized.push(argument); + index += 1; + continue; + } + break; + } + return [...normalized, ...args.slice(index)]; +}