Skip to content

Commit e31d48b

Browse files
committed
feat: add simulator-set and Android allowlist isolation
1 parent 2a86f52 commit e31d48b

19 files changed

Lines changed: 415 additions & 99 deletions

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ Flags:
209209
- `--device <name>`
210210
- `--udid <udid>` (iOS)
211211
- `--serial <serial>` (Android)
212+
- `--ios-simulator-device-set <path>` constrain iOS simulator discovery/commands to one simulator set (`xcrun simctl --set`)
213+
- `--android-device-allowlist <serials>` constrain Android discovery/selection to comma/space-separated serials
212214
- `--activity <component>` (Android app launch only; package/Activity or package/.Activity; not for URL opens)
213215
- `--session <name>`
214216
- `--state-dir <path>` daemon state directory override (default: `~/.agent-device`)
@@ -232,6 +234,11 @@ Flags:
232234
- `--on-error stop` batch: stop when a step fails
233235
- `--max-steps <n>` batch: max allowed steps per request
234236

237+
Isolation precedence:
238+
- Discovery scope (`--ios-simulator-device-set`, `--android-device-allowlist`) is applied before selector matching (`--device`, `--udid`, `--serial`).
239+
- If a selector points outside the scoped set/allowlist, command resolution fails with `DEVICE_NOT_FOUND` (no host-global fallback).
240+
- When `--ios-simulator-device-set` is set (or its env equivalent), iOS discovery is simulator-set only (physical iOS devices are not enumerated).
241+
235242
TV targets:
236243
- Use `--target tv` together with `--platform ios|android|apple`.
237244
- TV target selection supports both simulator/emulator and connected physical devices (AppleTV + AndroidTV).
@@ -465,6 +472,9 @@ pnpm build
465472
Environment selectors:
466473
- `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554`
467474
- `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=<udid>`
475+
- `AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET=<path>` (or `IOS_SIMULATOR_DEVICE_SET=<path>`) to scope all iOS simulator discovery/commands to one simulator set.
476+
- `AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST=<serials>` (or `ANDROID_DEVICE_ALLOWLIST=<serials>`) to scope Android discovery to allowlisted serials.
477+
- CLI flags `--ios-simulator-device-set` / `--android-device-allowlist` override environment values.
468478
- `AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS=<ms>` to adjust iOS simulator boot timeout (default: `120000`, minimum: `5000`).
469479
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS=<ms>` to override daemon request timeout (default `90000`). Increase for slow physical-device setup (for example `120000`).
470480
- `AGENT_DEVICE_STATE_DIR=<path>` override daemon state directory (metadata, logs, session artifacts).

src/core/dispatch.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type { RawSnapshotNode } from '../utils/snapshot.ts';
2828
import type { CliFlags } from '../utils/command-schema.ts';
2929
import { emitDiagnostic, withDiagnosticTimer } from '../utils/diagnostics.ts';
3030
import { resolvePayloadInput } from '../utils/payload-input.ts';
31+
import { resolveAndroidSerialAllowlist, resolveIosSimulatorDeviceSetPath } from '../utils/device-isolation.ts';
3132

3233
export type BatchStep = {
3334
command: string;
@@ -41,6 +42,8 @@ export type CommandFlags = Omit<CliFlags, 'json' | 'help' | 'version' | 'batchSt
4142

4243
export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceInfo> {
4344
const normalizedPlatform = normalizePlatformSelector(flags.platform);
45+
const iosSimulatorSetPath = resolveIosSimulatorDeviceSetPath(flags.iosSimulatorDeviceSet);
46+
const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags.androidDeviceAllowlist);
4447
return await withDiagnosticTimer(
4548
'resolve_target_device',
4649
async () => {
@@ -60,23 +63,23 @@ export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceIn
6063

6164
if (selector.platform === 'android') {
6265
await ensureAdb();
63-
const devices = await listAndroidDevices();
66+
const devices = await listAndroidDevices({ serialAllowlist: androidSerialAllowlist });
6467
return await selectDevice(devices, selector);
6568
}
6669

6770
if (selector.platform === 'ios') {
68-
const devices = await listIosDevices();
71+
const devices = await listIosDevices({ simulatorSetPath: iosSimulatorSetPath });
6972
return await selectDevice(devices, selector);
7073
}
7174

7275
const devices: DeviceInfo[] = [];
7376
try {
74-
devices.push(...(await listAndroidDevices()));
77+
devices.push(...(await listAndroidDevices({ serialAllowlist: androidSerialAllowlist })));
7578
} catch {
7679
// ignore
7780
}
7881
try {
79-
devices.push(...(await listIosDevices()));
82+
devices.push(...(await listIosDevices({ simulatorSetPath: iosSimulatorSetPath })));
8083
} catch {
8184
// ignore
8285
}

src/daemon/__tests__/session-selector.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,31 @@ test('rejects mismatched device selector', () => {
109109
err.message.includes('--device=thymikee-iphone'),
110110
);
111111
});
112+
113+
test('accepts matching ios simulator set selector for iOS simulator sessions', () => {
114+
const session = makeSession({
115+
device: {
116+
platform: 'ios',
117+
id: 'sim-1',
118+
name: 'iPhone 17',
119+
kind: 'simulator',
120+
target: 'mobile',
121+
booted: true,
122+
simulatorSetPath: '/tmp/tenant-a/simulator-set',
123+
},
124+
});
125+
assert.doesNotThrow(() =>
126+
assertSessionSelectorMatches(session, { iosSimulatorDeviceSet: '/tmp/tenant-a/simulator-set' }),
127+
);
128+
});
129+
130+
test('rejects android allowlist selector when session device is not allowlisted', () => {
131+
const session = makeSession();
132+
assert.throws(
133+
() => assertSessionSelectorMatches(session, { androidDeviceAllowlist: 'emulator-9999' }),
134+
(err: unknown) =>
135+
err instanceof AppError &&
136+
err.code === 'INVALID_ARGS' &&
137+
err.message.includes('--android-device-allowlist=emulator-9999'),
138+
);
139+
});

src/daemon/handlers/record-trace.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { runCmd, runCmdBackground } from '../../utils/exec.ts';
44
import { resolveTargetDevice, type CommandFlags } from '../../core/dispatch.ts';
55
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
66
import { runIosRunnerCommand, IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../../platforms/ios/runner-client.ts';
7+
import { buildSimctlArgsForDevice } from '../../platforms/ios/simctl.ts';
78
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
89
import { SessionStore } from '../session-store.ts';
910
import { ensureDeviceReady } from '../device-ready.ts';
@@ -185,7 +186,7 @@ export async function handleRecordTraceCommands(params: {
185186
}
186187
activeSession.recording = { platform: 'ios-device-runner', outPath: resolvedOut, remotePath };
187188
} else if (device.platform === 'ios') {
188-
const { child, wait } = deps.runCmdBackground('xcrun', ['simctl', 'io', device.id, 'recordVideo', resolvedOut], {
189+
const { child, wait } = deps.runCmdBackground('xcrun', buildSimctlArgsForDevice(device, ['io', device.id, 'recordVideo', resolvedOut]), {
189190
allowFailure: true,
190191
});
191192
activeSession.recording = { platform: 'ios', outPath: resolvedOut, child, wait };

src/daemon/handlers/session.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
1010
import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
1111
import { AppError, asAppError, normalizeError } from '../../utils/errors.ts';
1212
import { normalizePlatformSelector, type DeviceInfo } from '../../utils/device.ts';
13+
import { resolveAndroidSerialAllowlist, resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts';
1314
import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
1415
import { SessionStore } from '../session-store.ts';
1516
import { contextFromFlags } from '../context.ts';
@@ -516,31 +517,34 @@ export async function handleSessionCommands(params: {
516517
if (command === 'devices') {
517518
try {
518519
const devices: DeviceInfo[] = [];
520+
const iosSimulatorSetPath = resolveIosSimulatorDeviceSetPath(req.flags?.iosSimulatorDeviceSet);
521+
const androidSerialAllowlist = resolveAndroidSerialAllowlist(req.flags?.androidDeviceAllowlist);
519522
const requestedPlatform = normalizePlatformSelector(req.flags?.platform);
520523
if (requestedPlatform === 'android') {
521524
const { listAndroidDevices } = await import('../../platforms/android/devices.ts');
522-
devices.push(...(await listAndroidDevices()));
525+
devices.push(...(await listAndroidDevices({ serialAllowlist: androidSerialAllowlist })));
523526
} else if (requestedPlatform === 'ios') {
524527
const { listIosDevices } = await import('../../platforms/ios/devices.ts');
525-
devices.push(...(await listIosDevices()));
528+
devices.push(...(await listIosDevices({ simulatorSetPath: iosSimulatorSetPath })));
526529
} else {
527530
const { listAndroidDevices } = await import('../../platforms/android/devices.ts');
528531
const { listIosDevices } = await import('../../platforms/ios/devices.ts');
529532
try {
530-
devices.push(...(await listAndroidDevices()));
533+
devices.push(...(await listAndroidDevices({ serialAllowlist: androidSerialAllowlist })));
531534
} catch {
532535
// ignore
533536
}
534537
try {
535-
devices.push(...(await listIosDevices()));
538+
devices.push(...(await listIosDevices({ simulatorSetPath: iosSimulatorSetPath })));
536539
} catch {
537540
// ignore
538541
}
539542
}
540543
const filtered = req.flags?.target
541544
? devices.filter((device) => (device.target ?? 'mobile') === req.flags?.target)
542545
: devices;
543-
return { ok: true, data: { devices: filtered } };
546+
const publicDevices = filtered.map(({ simulatorSetPath: _simulatorSetPath, ...device }) => device);
547+
return { ok: true, data: { devices: publicDevices } };
544548
} catch (err) {
545549
const appErr = asAppError(err);
546550
return { ok: false, error: { code: appErr.code, message: appErr.message, details: appErr.details } };

src/daemon/session-selector.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AppError } from '../utils/errors.ts';
22
import type { CommandFlags } from '../core/dispatch.ts';
33
import type { SessionState } from './types.ts';
44
import { normalizePlatformSelector } from '../utils/device.ts';
5+
import { parseSerialAllowlist } from '../utils/device-isolation.ts';
56

67
export function assertSessionSelectorMatches(
78
session: SessionState,
@@ -32,6 +33,25 @@ export function assertSessionSelectorMatches(
3233
mismatches.push(`--device=${flags.device}`);
3334
}
3435

36+
if (flags.iosSimulatorDeviceSet) {
37+
const requestedSetPath = flags.iosSimulatorDeviceSet.trim();
38+
const sessionSetPath = device.simulatorSetPath?.trim();
39+
if (
40+
device.platform !== 'ios'
41+
|| device.kind !== 'simulator'
42+
|| requestedSetPath !== sessionSetPath
43+
) {
44+
mismatches.push(`--ios-simulator-device-set=${flags.iosSimulatorDeviceSet}`);
45+
}
46+
}
47+
48+
if (flags.androidDeviceAllowlist) {
49+
const allowlist = parseSerialAllowlist(flags.androidDeviceAllowlist);
50+
if (device.platform !== 'android' || !allowlist.has(device.id)) {
51+
mismatches.push(`--android-device-allowlist=${flags.androidDeviceAllowlist}`);
52+
}
53+
}
54+
3555
if (mismatches.length === 0) return;
3656

3757
throw new AppError(

src/platforms/android/devices.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ExecResult } from '../../utils/exec.ts';
33
import { AppError, asAppError } from '../../utils/errors.ts';
44
import type { DeviceInfo } from '../../utils/device.ts';
55
import { Deadline, retryWithPolicy, TIMEOUT_PROFILES } from '../../utils/retry.ts';
6+
import { resolveAndroidSerialAllowlist } from '../../utils/device-isolation.ts';
67
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
78

89
const EMULATOR_SERIAL_PREFIX = 'emulator-';
@@ -16,6 +17,10 @@ const ANDROID_TV_FEATURES = [
1617
'android.hardware.type.television',
1718
] as const;
1819

20+
type AndroidDeviceDiscoveryOptions = {
21+
serialAllowlist?: ReadonlySet<string>;
22+
};
23+
1924
function commandOutput(result: ExecResult): string {
2025
return `${result.stdout}\n${result.stderr}`;
2126
}
@@ -136,15 +141,20 @@ async function resolveAndroidTarget(serial: string): Promise<'mobile' | 'tv'> {
136141
return 'mobile';
137142
}
138143

139-
export async function listAndroidDevices(): Promise<DeviceInfo[]> {
144+
export async function listAndroidDevices(options: AndroidDeviceDiscoveryOptions = {}): Promise<DeviceInfo[]> {
140145
const adbAvailable = await whichCmd('adb');
141146
if (!adbAvailable) {
142147
throw new AppError('TOOL_MISSING', 'adb not found in PATH');
143148
}
149+
const serialAllowlist =
150+
options.serialAllowlist
151+
?? resolveAndroidSerialAllowlist(undefined);
144152

145153
const entries = await listAndroidDeviceEntries();
154+
const filteredEntries = entries.filter((entry) =>
155+
!serialAllowlist || serialAllowlist.has(entry.serial));
146156

147-
const devices = await Promise.all(entries.map(async ({ serial, rawModel }) => {
157+
const devices = await Promise.all(filteredEntries.map(async ({ serial, rawModel }) => {
148158
const [name, booted, target] = await Promise.all([
149159
resolveAndroidDeviceName(serial, rawModel),
150160
isAndroidBooted(serial),
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { buildSimctlArgs, buildSimctlArgsForDevice } from '../simctl.ts';
4+
import type { DeviceInfo } from '../../../utils/device.ts';
5+
6+
const IOS_SIMULATOR: DeviceInfo = {
7+
platform: 'ios',
8+
id: 'sim-1',
9+
name: 'iPhone 17',
10+
kind: 'simulator',
11+
target: 'mobile',
12+
};
13+
14+
test('buildSimctlArgs uses --set when simulator set path is provided', () => {
15+
const args = buildSimctlArgs(['list', 'devices', '-j'], {
16+
simulatorSetPath: '/tmp/tenant-a/simulator-set',
17+
});
18+
assert.deepEqual(args, ['simctl', '--set', '/tmp/tenant-a/simulator-set', 'list', 'devices', '-j']);
19+
});
20+
21+
test('buildSimctlArgsForDevice includes simulator set from device metadata', () => {
22+
const args = buildSimctlArgsForDevice(
23+
{ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-b/simulator-set' },
24+
['bootstatus', 'sim-1', '-b'],
25+
);
26+
assert.deepEqual(args, ['simctl', '--set', '/tmp/tenant-b/simulator-set', 'bootstatus', 'sim-1', '-b']);
27+
});
28+
29+
test('buildSimctlArgsForDevice leaves non-simulator commands unchanged', () => {
30+
const args = buildSimctlArgsForDevice(
31+
{ ...IOS_SIMULATOR, kind: 'device' },
32+
['bootstatus', 'sim-1', '-b'],
33+
);
34+
assert.deepEqual(args, ['simctl', 'bootstatus', 'sim-1', '-b']);
35+
});

0 commit comments

Comments
 (0)