agent-device 0.3.5 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +47 -14
  2. package/dist/src/797.js +1 -0
  3. package/dist/src/bin.js +44 -95
  4. package/dist/src/daemon.js +18 -17
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
  6. package/ios-runner/README.md +1 -1
  7. package/package.json +2 -2
  8. package/skills/agent-device/SKILL.md +25 -12
  9. package/skills/agent-device/references/permissions.md +15 -1
  10. package/skills/agent-device/references/session-management.md +3 -0
  11. package/skills/agent-device/references/snapshot-refs.md +2 -0
  12. package/skills/agent-device/references/video-recording.md +2 -0
  13. package/src/__tests__/cli-help.test.ts +102 -0
  14. package/src/cli.ts +42 -8
  15. package/src/core/__tests__/capabilities.test.ts +11 -6
  16. package/src/core/capabilities.ts +26 -20
  17. package/src/core/dispatch.ts +109 -31
  18. package/src/daemon/__tests__/app-state.test.ts +138 -0
  19. package/src/daemon/__tests__/session-store.test.ts +23 -0
  20. package/src/daemon/app-state.ts +37 -38
  21. package/src/daemon/context.ts +12 -0
  22. package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
  23. package/src/daemon/handlers/__tests__/session.test.ts +8 -5
  24. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
  25. package/src/daemon/handlers/interaction.ts +37 -0
  26. package/src/daemon/handlers/record-trace.ts +1 -1
  27. package/src/daemon/handlers/session.ts +3 -3
  28. package/src/daemon/handlers/snapshot.ts +230 -187
  29. package/src/daemon/session-store.ts +16 -4
  30. package/src/daemon/types.ts +2 -1
  31. package/src/daemon-client.ts +42 -13
  32. package/src/daemon.ts +99 -9
  33. package/src/platforms/android/__tests__/index.test.ts +46 -1
  34. package/src/platforms/android/index.ts +23 -0
  35. package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
  36. package/src/platforms/ios/devices.ts +40 -18
  37. package/src/platforms/ios/index.ts +2 -2
  38. package/src/platforms/ios/runner-client.ts +418 -93
  39. package/src/utils/__tests__/args.test.ts +208 -1
  40. package/src/utils/__tests__/daemon-client.test.ts +78 -0
  41. package/src/utils/__tests__/keyed-lock.test.ts +55 -0
  42. package/src/utils/__tests__/process-identity.test.ts +33 -0
  43. package/src/utils/args.ts +202 -215
  44. package/src/utils/command-schema.ts +629 -0
  45. package/src/utils/interactors.ts +11 -1
  46. package/src/utils/keyed-lock.ts +14 -0
  47. package/src/utils/process-identity.ts +100 -0
  48. package/dist/src/274.js +0 -1
@@ -6,6 +6,7 @@ import { AppError } from './utils/errors.ts';
6
6
  import type { CommandFlags } from './core/dispatch.ts';
7
7
  import { runCmdDetached } from './utils/exec.ts';
8
8
  import { findProjectRoot, readVersion } from './utils/version.ts';
9
+ import { stopProcessForTakeover } from './utils/process-identity.ts';
9
10
 
10
11
  export type DaemonRequest = {
11
12
  token: string;
@@ -19,12 +20,20 @@ export type DaemonResponse =
19
20
  | { ok: true; data?: Record<string, unknown> }
20
21
  | { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
21
22
 
22
- type DaemonInfo = { port: number; token: string; pid: number; version?: string };
23
+ type DaemonInfo = {
24
+ port: number;
25
+ token: string;
26
+ pid: number;
27
+ version?: string;
28
+ processStartTime?: string;
29
+ };
23
30
 
24
31
  const baseDir = path.join(os.homedir(), '.agent-device');
25
32
  const infoPath = path.join(baseDir, 'daemon.json');
26
- const REQUEST_TIMEOUT_MS = resolveRequestTimeoutMs();
33
+ const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
27
34
  const DAEMON_STARTUP_TIMEOUT_MS = 5000;
35
+ const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
36
+ const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000;
28
37
 
29
38
  export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
30
39
  const info = await ensureDaemon();
@@ -35,8 +44,10 @@ export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<D
35
44
  async function ensureDaemon(): Promise<DaemonInfo> {
36
45
  const existing = readDaemonInfo();
37
46
  const localVersion = readVersion();
38
- if (existing && existing.version === localVersion && (await canConnect(existing))) return existing;
39
- if (existing && (existing.version !== localVersion || !(await canConnect(existing)))) {
47
+ const existingReachable = existing ? await canConnect(existing) : false;
48
+ if (existing && existing.version === localVersion && existingReachable) return existing;
49
+ if (existing && (existing.version !== localVersion || !existingReachable)) {
50
+ await stopDaemonProcessForTakeover(existing);
40
51
  removeDaemonInfo();
41
52
  }
42
53
 
@@ -55,19 +66,34 @@ async function ensureDaemon(): Promise<DaemonInfo> {
55
66
  });
56
67
  }
57
68
 
69
+ async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
70
+ await stopProcessForTakeover(info.pid, {
71
+ termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
72
+ killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
73
+ expectedStartTime: info.processStartTime,
74
+ });
75
+ }
76
+
58
77
  function readDaemonInfo(): DaemonInfo | null {
59
78
  if (!fs.existsSync(infoPath)) return null;
60
79
  try {
61
80
  const data = JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo;
62
81
  if (!data.port || !data.token) return null;
63
- return data;
82
+ return {
83
+ ...data,
84
+ pid: Number.isInteger(data.pid) && data.pid > 0 ? data.pid : 0,
85
+ };
64
86
  } catch {
65
87
  return null;
66
88
  }
67
89
  }
68
90
 
69
91
  function removeDaemonInfo(): void {
70
- if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
92
+ try {
93
+ if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
94
+ } catch {
95
+ // Best-effort cleanup only; daemon can still overwrite this file on startup.
96
+ }
71
97
  }
72
98
 
73
99
  async function canConnect(info: DaemonInfo): Promise<boolean> {
@@ -87,11 +113,14 @@ async function startDaemon(): Promise<void> {
87
113
  const distPath = path.join(root, 'dist', 'src', 'daemon.js');
88
114
  const srcPath = path.join(root, 'src', 'daemon.ts');
89
115
 
90
- const useDist = fs.existsSync(distPath);
91
- if (!useDist && !fs.existsSync(srcPath)) {
116
+ const hasDist = fs.existsSync(distPath);
117
+ const hasSrc = fs.existsSync(srcPath);
118
+ if (!hasDist && !hasSrc) {
92
119
  throw new AppError('COMMAND_FAILED', 'Daemon entry not found', { distPath, srcPath });
93
120
  }
94
- const args = useDist ? [distPath] : ['--experimental-strip-types', srcPath];
121
+ const runningFromSource = process.execArgv.includes('--experimental-strip-types');
122
+ const useSrc = runningFromSource ? hasSrc : !hasDist && hasSrc;
123
+ const args = useSrc ? ['--experimental-strip-types', srcPath] : [distPath];
95
124
 
96
125
  runCmdDetached(process.execPath, args);
97
126
  }
@@ -134,10 +163,10 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
134
163
  });
135
164
  }
136
165
 
137
- function resolveRequestTimeoutMs(): number {
138
- const raw = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS;
139
- if (!raw) return 60000;
166
+ export function resolveDaemonRequestTimeoutMs(raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS): number {
167
+ // iOS physical-device runner startup/build can exceed 60s, so use a safer default for daemon RPCs.
168
+ if (!raw) return 180000;
140
169
  const parsed = Number(raw);
141
- if (!Number.isFinite(parsed)) return 60000;
170
+ if (!Number.isFinite(parsed)) return 180000;
142
171
  return Math.max(1000, Math.floor(parsed));
143
172
  }
package/src/daemon.ts CHANGED
@@ -7,7 +7,7 @@ import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
7
7
  import { isCommandSupportedOnDevice } from './core/capabilities.ts';
8
8
  import { asAppError, AppError } from './utils/errors.ts';
9
9
  import { readVersion } from './utils/version.ts';
10
- import { stopIosRunnerSession } from './platforms/ios/runner-client.ts';
10
+ import { stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts';
11
11
  import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
12
12
  import { SessionStore } from './daemon/session-store.ts';
13
13
  import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
@@ -18,9 +18,14 @@ import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
18
18
  import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
19
19
  import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
20
20
  import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
21
+ import {
22
+ isAgentDeviceDaemonProcess,
23
+ readProcessStartTime,
24
+ } from './utils/process-identity.ts';
21
25
 
22
26
  const baseDir = path.join(os.homedir(), '.agent-device');
23
27
  const infoPath = path.join(baseDir, 'daemon.json');
28
+ const lockPath = path.join(baseDir, 'daemon.lock');
24
29
  const logPath = path.join(baseDir, 'daemon.log');
25
30
  const sessionsDir = path.join(baseDir, 'sessions');
26
31
  const sessionStore = new SessionStore(sessionsDir);
@@ -28,6 +33,15 @@ const version = readVersion();
28
33
  const token = crypto.randomBytes(24).toString('hex');
29
34
  const selectorValidationExemptCommands = new Set(['session_list', 'devices']);
30
35
 
36
+ type DaemonLockInfo = {
37
+ pid: number;
38
+ version: string;
39
+ startedAt: number;
40
+ processStartTime?: string;
41
+ };
42
+
43
+ const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined;
44
+
31
45
  function contextFromFlags(
32
46
  flags: CommandFlags | undefined,
33
47
  appBundleId?: string,
@@ -122,7 +136,7 @@ function writeInfo(port: number): void {
122
136
  fs.writeFileSync(logPath, '');
123
137
  fs.writeFileSync(
124
138
  infoPath,
125
- JSON.stringify({ port, token, pid: process.pid, version }, null, 2),
139
+ JSON.stringify({ port, token, pid: process.pid, version, processStartTime: daemonProcessStartTime }, null, 2),
126
140
  {
127
141
  mode: 0o600,
128
142
  },
@@ -133,7 +147,73 @@ function removeInfo(): void {
133
147
  if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
134
148
  }
135
149
 
150
+ function readLockInfo(): DaemonLockInfo | null {
151
+ if (!fs.existsSync(lockPath)) return null;
152
+ try {
153
+ const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as DaemonLockInfo;
154
+ if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
155
+ return parsed;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ function acquireDaemonLock(): boolean {
162
+ if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
163
+ const lockData: DaemonLockInfo = {
164
+ pid: process.pid,
165
+ version,
166
+ startedAt: Date.now(),
167
+ processStartTime: daemonProcessStartTime,
168
+ };
169
+ const payload = JSON.stringify(lockData, null, 2);
170
+
171
+ const tryWriteLock = (): boolean => {
172
+ try {
173
+ fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
174
+ return true;
175
+ } catch (err) {
176
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
177
+ throw err;
178
+ }
179
+ };
180
+
181
+ if (tryWriteLock()) return true;
182
+ const existing = readLockInfo();
183
+ if (
184
+ existing?.pid
185
+ && existing.pid !== process.pid
186
+ && isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime)
187
+ ) {
188
+ return false;
189
+ }
190
+ // Best-effort stale-lock cleanup: another process may win the race between unlink and re-create.
191
+ // We rely on the subsequent write with `wx` to enforce single-writer semantics.
192
+ try {
193
+ fs.unlinkSync(lockPath);
194
+ } catch {
195
+ // ignore
196
+ }
197
+ return tryWriteLock();
198
+ }
199
+
200
+ function releaseDaemonLock(): void {
201
+ const existing = readLockInfo();
202
+ if (existing && existing.pid !== process.pid) return;
203
+ try {
204
+ if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
205
+ } catch {
206
+ // ignore
207
+ }
208
+ }
209
+
136
210
  function start(): void {
211
+ if (!acquireDaemonLock()) {
212
+ process.stderr.write('Daemon lock is held by another process; exiting.\n');
213
+ process.exit(0);
214
+ return;
215
+ }
216
+
137
217
  const server = net.createServer((socket) => {
138
218
  let buffer = '';
139
219
  socket.setEncoding('utf8');
@@ -172,18 +252,28 @@ function start(): void {
172
252
  }
173
253
  });
174
254
 
255
+ let shuttingDown = false;
256
+ const closeServer = async (): Promise<void> => {
257
+ await new Promise<void>((resolve) => {
258
+ try {
259
+ server.close(() => resolve());
260
+ } catch {
261
+ resolve();
262
+ }
263
+ });
264
+ };
175
265
  const shutdown = async () => {
266
+ if (shuttingDown) return;
267
+ shuttingDown = true;
268
+ await closeServer();
176
269
  const sessionsToStop = sessionStore.toArray();
177
270
  for (const session of sessionsToStop) {
178
- if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
179
- await stopIosRunnerSession(session.device.id);
180
- }
181
271
  sessionStore.writeSessionLog(session);
182
272
  }
183
- server.close(() => {
184
- removeInfo();
185
- process.exit(0);
186
- });
273
+ await stopAllIosRunnerSessions();
274
+ removeInfo();
275
+ releaseDaemonLock();
276
+ process.exit(0);
187
277
  };
188
278
 
189
279
  process.on('SIGINT', () => {
@@ -1,6 +1,9 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { openAndroidApp, parseAndroidLaunchComponent } from '../index.ts';
3
+ import { promises as fs } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { openAndroidApp, parseAndroidLaunchComponent, swipeAndroid } from '../index.ts';
4
7
  import type { DeviceInfo } from '../../../utils/device.ts';
5
8
  import { AppError } from '../../../utils/errors.ts';
6
9
  import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
@@ -110,3 +113,45 @@ test('openAndroidApp rejects activity override for deep link URLs', async () =>
110
113
  },
111
114
  );
112
115
  });
116
+
117
+ test('swipeAndroid invokes adb input swipe with duration', async () => {
118
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-swipe-test-'));
119
+ const adbPath = path.join(tmpDir, 'adb');
120
+ const argsLogPath = path.join(tmpDir, 'args.log');
121
+ await fs.writeFile(
122
+ adbPath,
123
+ '#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
124
+ 'utf8',
125
+ );
126
+ await fs.chmod(adbPath, 0o755);
127
+
128
+ const previousPath = process.env.PATH;
129
+ const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
130
+ process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
131
+ process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
132
+
133
+ const device: DeviceInfo = {
134
+ platform: 'android',
135
+ id: 'emulator-5554',
136
+ name: 'Pixel',
137
+ kind: 'emulator',
138
+ booted: true,
139
+ };
140
+
141
+ try {
142
+ await swipeAndroid(device, 10, 20, 30, 40, 250);
143
+ const args = (await fs.readFile(argsLogPath, 'utf8'))
144
+ .trim()
145
+ .split('\n')
146
+ .filter(Boolean);
147
+ assert.deepEqual(args, ['-s', 'emulator-5554', 'shell', 'input', 'swipe', '10', '20', '30', '40', '250']);
148
+ } finally {
149
+ process.env.PATH = previousPath;
150
+ if (previousArgsFile === undefined) {
151
+ delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
152
+ } else {
153
+ process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
154
+ }
155
+ await fs.rm(tmpDir, { recursive: true, force: true });
156
+ }
157
+ });
@@ -333,6 +333,29 @@ export async function pressAndroid(device: DeviceInfo, x: number, y: number): Pr
333
333
  await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
334
334
  }
335
335
 
336
+ export async function swipeAndroid(
337
+ device: DeviceInfo,
338
+ x1: number,
339
+ y1: number,
340
+ x2: number,
341
+ y2: number,
342
+ durationMs = 250,
343
+ ): Promise<void> {
344
+ await runCmd(
345
+ 'adb',
346
+ adbArgs(device, [
347
+ 'shell',
348
+ 'input',
349
+ 'swipe',
350
+ String(x1),
351
+ String(y1),
352
+ String(x2),
353
+ String(y2),
354
+ String(durationMs),
355
+ ]),
356
+ );
357
+ }
358
+
336
359
  export async function backAndroid(device: DeviceInfo): Promise<void> {
337
360
  await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '4']));
338
361
  }
@@ -0,0 +1,113 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import type { DeviceInfo } from '../../../utils/device.ts';
4
+ import {
5
+ assertSafeDerivedCleanup,
6
+ resolveRunnerBuildDestination,
7
+ resolveRunnerDestination,
8
+ resolveRunnerMaxConcurrentDestinationsFlag,
9
+ resolveRunnerSigningBuildSettings,
10
+ } from '../runner-client.ts';
11
+
12
+ const iosSimulator: DeviceInfo = {
13
+ platform: 'ios',
14
+ id: 'sim-1',
15
+ name: 'iPhone Simulator',
16
+ kind: 'simulator',
17
+ booted: true,
18
+ };
19
+
20
+ const iosDevice: DeviceInfo = {
21
+ platform: 'ios',
22
+ id: '00008110-000E12341234002E',
23
+ name: 'iPhone',
24
+ kind: 'device',
25
+ booted: true,
26
+ };
27
+
28
+ test('resolveRunnerDestination uses simulator destination for simulators', () => {
29
+ assert.equal(resolveRunnerDestination(iosSimulator), 'platform=iOS Simulator,id=sim-1');
30
+ });
31
+
32
+ test('resolveRunnerDestination uses device destination for physical devices', () => {
33
+ assert.equal(
34
+ resolveRunnerDestination(iosDevice),
35
+ 'platform=iOS,id=00008110-000E12341234002E',
36
+ );
37
+ });
38
+
39
+ test('resolveRunnerBuildDestination uses generic iOS destination for physical devices', () => {
40
+ assert.equal(resolveRunnerBuildDestination(iosDevice), 'generic/platform=iOS');
41
+ });
42
+
43
+ test('resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators', () => {
44
+ assert.equal(
45
+ resolveRunnerMaxConcurrentDestinationsFlag(iosSimulator),
46
+ '-maximum-concurrent-test-simulator-destinations',
47
+ );
48
+ });
49
+
50
+ test('resolveRunnerMaxConcurrentDestinationsFlag uses device flag for physical devices', () => {
51
+ assert.equal(
52
+ resolveRunnerMaxConcurrentDestinationsFlag(iosDevice),
53
+ '-maximum-concurrent-test-device-destinations',
54
+ );
55
+ });
56
+
57
+ test('resolveRunnerSigningBuildSettings returns empty args without env overrides', () => {
58
+ assert.deepEqual(resolveRunnerSigningBuildSettings({}), []);
59
+ });
60
+
61
+ test('resolveRunnerSigningBuildSettings enables automatic signing for device builds without forcing identity', () => {
62
+ assert.deepEqual(resolveRunnerSigningBuildSettings({}, true), [
63
+ 'CODE_SIGN_STYLE=Automatic',
64
+ ]);
65
+ });
66
+
67
+ test('resolveRunnerSigningBuildSettings ignores device signing overrides for simulator builds', () => {
68
+ assert.deepEqual(resolveRunnerSigningBuildSettings({
69
+ AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345',
70
+ AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development',
71
+ AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile',
72
+ }, false), []);
73
+ });
74
+
75
+ test('resolveRunnerSigningBuildSettings applies optional overrides when provided', () => {
76
+ const settings = resolveRunnerSigningBuildSettings({
77
+ AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345',
78
+ AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development',
79
+ AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile',
80
+ }, true);
81
+ assert.deepEqual(settings, [
82
+ 'CODE_SIGN_STYLE=Automatic',
83
+ 'DEVELOPMENT_TEAM=ABCDE12345',
84
+ 'CODE_SIGN_IDENTITY=Apple Development',
85
+ 'PROVISIONING_PROFILE_SPECIFIER=My Profile',
86
+ ]);
87
+ });
88
+
89
+ test('assertSafeDerivedCleanup allows cleaning when no override is set', () => {
90
+ assert.doesNotThrow(() => {
91
+ assertSafeDerivedCleanup('/tmp/derived', {});
92
+ });
93
+ });
94
+
95
+ test('assertSafeDerivedCleanup rejects cleaning override path by default', () => {
96
+ assert.throws(
97
+ () => {
98
+ assertSafeDerivedCleanup('/tmp/custom', {
99
+ AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: '/tmp/custom',
100
+ });
101
+ },
102
+ /Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically/,
103
+ );
104
+ });
105
+
106
+ test('assertSafeDerivedCleanup allows cleaning override path with explicit opt-in', () => {
107
+ assert.doesNotThrow(() => {
108
+ assertSafeDerivedCleanup('/tmp/custom', {
109
+ AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: '/tmp/custom',
110
+ AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN: '1',
111
+ });
112
+ });
113
+ });
@@ -1,3 +1,6 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
1
4
  import { runCmd, whichCmd } from '../../utils/exec.ts';
2
5
  import { AppError } from '../../utils/errors.ts';
3
6
  import type { DeviceInfo } from '../../utils/device.ts';
@@ -38,26 +41,45 @@ export async function listIosDevices(): Promise<DeviceInfo[]> {
38
41
  throw new AppError('COMMAND_FAILED', 'Failed to parse simctl devices JSON', undefined, err);
39
42
  }
40
43
 
41
- const devicectlAvailable = await whichCmd('xcrun');
42
- if (devicectlAvailable) {
43
- try {
44
- const result = await runCmd('xcrun', ['devicectl', 'list', 'devices', '--json']);
45
- const payload = JSON.parse(result.stdout as string) as {
46
- devices: { identifier: string; name: string; platform: string }[];
44
+ let jsonPath: string | null = null;
45
+ try {
46
+ jsonPath = path.join(
47
+ os.tmpdir(),
48
+ `agent-device-devicectl-${process.pid}-${Date.now()}.json`,
49
+ );
50
+ await runCmd('xcrun', ['devicectl', 'list', 'devices', '--json-output', jsonPath]);
51
+ const jsonText = await fs.readFile(jsonPath, 'utf8');
52
+ const payload = JSON.parse(jsonText) as {
53
+ result?: {
54
+ devices?: Array<{
55
+ identifier?: string;
56
+ name?: string;
57
+ hardwareProperties?: { platform?: string; udid?: string };
58
+ deviceProperties?: { name?: string };
59
+ connectionProperties?: { tunnelState?: string };
60
+ }>;
47
61
  };
48
- for (const device of payload.devices ?? []) {
49
- if (device.platform?.toLowerCase().includes('ios')) {
50
- devices.push({
51
- platform: 'ios',
52
- id: device.identifier,
53
- name: device.name,
54
- kind: 'device',
55
- booted: true,
56
- });
57
- }
62
+ };
63
+ for (const device of payload.result?.devices ?? []) {
64
+ const platform = device.hardwareProperties?.platform ?? '';
65
+ if (platform.toLowerCase().includes('ios')) {
66
+ const id = device.hardwareProperties?.udid ?? device.identifier ?? '';
67
+ const name = device.name ?? device.deviceProperties?.name ?? id;
68
+ if (!id) continue;
69
+ devices.push({
70
+ platform: 'ios',
71
+ id,
72
+ name,
73
+ kind: 'device',
74
+ booted: true,
75
+ });
58
76
  }
59
- } catch {
60
- // Ignore devicectl failures; simulators are still supported.
77
+ }
78
+ } catch {
79
+ // Ignore devicectl failures; simulators are still supported.
80
+ } finally {
81
+ if (jsonPath) {
82
+ await fs.rm(jsonPath, { force: true }).catch(() => {});
61
83
  }
62
84
  }
63
85
 
@@ -54,7 +54,7 @@ export async function openIosApp(
54
54
  const deepLinkTarget = app.trim();
55
55
  if (isDeepLinkTarget(deepLinkTarget)) {
56
56
  if (device.kind !== 'simulator') {
57
- throw new AppError('UNSUPPORTED_OPERATION', 'Deep link open is only supported on iOS simulators in v1');
57
+ throw new AppError('UNSUPPORTED_OPERATION', 'Deep link open is only supported on iOS simulators');
58
58
  }
59
59
  await ensureBootedSimulator(device);
60
60
  await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
@@ -249,7 +249,7 @@ function ensureSimulator(device: DeviceInfo, command: string): void {
249
249
  if (device.kind !== 'simulator') {
250
250
  throw new AppError(
251
251
  'UNSUPPORTED_OPERATION',
252
- `${command} is only supported on iOS simulators in v1`,
252
+ `${command} is only supported on iOS simulators`,
253
253
  );
254
254
  }
255
255
  }