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
@@ -4,8 +4,10 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { AppError } from '../../utils/errors.ts';
6
6
  import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
7
- import { withRetry } from '../../utils/retry.ts';
7
+ import { Deadline, isEnvTruthy, retryWithPolicy, withRetry } from '../../utils/retry.ts';
8
8
  import type { DeviceInfo } from '../../utils/device.ts';
9
+ import { withKeyedLock } from '../../utils/keyed-lock.ts';
10
+ import { isProcessAlive } from '../../utils/process-identity.ts';
9
11
  import net from 'node:net';
10
12
  import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
11
13
 
@@ -13,6 +15,7 @@ export type RunnerCommand = {
13
15
  command:
14
16
  | 'tap'
15
17
  | 'longPress'
18
+ | 'drag'
16
19
  | 'type'
17
20
  | 'swipe'
18
21
  | 'findText'
@@ -29,6 +32,8 @@ export type RunnerCommand = {
29
32
  action?: 'get' | 'accept' | 'dismiss';
30
33
  x?: number;
31
34
  y?: number;
35
+ x2?: number;
36
+ y2?: number;
32
37
  durationMs?: number;
33
38
  direction?: 'up' | 'down' | 'left' | 'right';
34
39
  scale?: number;
@@ -52,6 +57,7 @@ export type RunnerSession = {
52
57
  };
53
58
 
54
59
  const runnerSessions = new Map<string, RunnerSession>();
60
+ const runnerSessionLocks = new Map<string, Promise<unknown>>();
55
61
  const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
56
62
  process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
57
63
  120_000,
@@ -62,8 +68,34 @@ const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
62
68
  15_000,
63
69
  1_000,
64
70
  );
71
+ const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs(
72
+ process.env.AGENT_DEVICE_RUNNER_CONNECT_ATTEMPT_INTERVAL_MS,
73
+ 250,
74
+ 50,
75
+ );
76
+ const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = resolveTimeoutMs(
77
+ process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
78
+ 100,
79
+ 10,
80
+ );
81
+ const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs(
82
+ process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
83
+ 500,
84
+ 10,
85
+ );
86
+ const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
87
+ process.env.AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS,
88
+ 1_000,
89
+ 50,
90
+ );
91
+ const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs(
92
+ process.env.AGENT_DEVICE_IOS_DEVICE_INFO_TIMEOUT_MS,
93
+ 10_000,
94
+ 500,
95
+ );
65
96
  const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
66
97
  const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
98
+ const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner');
67
99
 
68
100
  function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
69
101
  if (!raw) return fallback;
@@ -89,6 +121,7 @@ export async function runIosRunnerCommand(
89
121
  command: RunnerCommand,
90
122
  options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
91
123
  ): Promise<Record<string, unknown>> {
124
+ validateRunnerDevice(device);
92
125
  if (isReadOnlyRunnerCommand(command.command)) {
93
126
  return withRetry(
94
127
  () => executeRunnerCommand(device, command, options),
@@ -98,17 +131,18 @@ export async function runIosRunnerCommand(
98
131
  return executeRunnerCommand(device, command, options);
99
132
  }
100
133
 
134
+ function withRunnerSessionLock<T>(deviceId: string, task: () => Promise<T>): Promise<T> {
135
+ return withKeyedLock(runnerSessionLocks, deviceId, task);
136
+ }
137
+
101
138
  async function executeRunnerCommand(
102
139
  device: DeviceInfo,
103
140
  command: RunnerCommand,
104
141
  options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
105
142
  ): Promise<Record<string, unknown>> {
106
- if (device.kind !== 'simulator') {
107
- throw new AppError('UNSUPPORTED_OPERATION', 'iOS runner only supports simulators in v1');
108
- }
109
-
143
+ let session: RunnerSession | undefined;
110
144
  try {
111
- const session = await ensureRunnerSession(device, options);
145
+ session = await ensureRunnerSession(device, options);
112
146
  const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS;
113
147
  return await executeRunnerCommandWithSession(
114
148
  device,
@@ -124,8 +158,12 @@ async function executeRunnerCommand(
124
158
  typeof appErr.message === 'string' &&
125
159
  appErr.message.includes('Runner did not accept connection')
126
160
  ) {
127
- await stopIosRunnerSession(device.id);
128
- const session = await ensureRunnerSession(device, options);
161
+ if (session) {
162
+ await stopRunnerSession(session);
163
+ } else {
164
+ await stopIosRunnerSession(device.id);
165
+ }
166
+ session = await ensureRunnerSession(device, options);
129
167
  const response = await waitForRunner(
130
168
  session.device,
131
169
  session.port,
@@ -178,7 +216,27 @@ async function parseRunnerResponse(
178
216
  }
179
217
 
180
218
  export async function stopIosRunnerSession(deviceId: string): Promise<void> {
181
- const session = runnerSessions.get(deviceId);
219
+ await withRunnerSessionLock(deviceId, async () => {
220
+ await stopRunnerSessionInternal(deviceId);
221
+ });
222
+ }
223
+
224
+ export async function stopAllIosRunnerSessions(): Promise<void> {
225
+ // Shutdown cleanup drains the sessions known at invocation time; daemon shutdown closes intake.
226
+ const pending = Array.from(runnerSessions.keys());
227
+ await Promise.allSettled(pending.map(async (deviceId) => {
228
+ await stopIosRunnerSession(deviceId);
229
+ }));
230
+ }
231
+
232
+ async function stopRunnerSession(session: RunnerSession): Promise<void> {
233
+ await withRunnerSessionLock(session.deviceId, async () => {
234
+ await stopRunnerSessionInternal(session.deviceId, session);
235
+ });
236
+ }
237
+
238
+ async function stopRunnerSessionInternal(deviceId: string, sessionOverride?: RunnerSession): Promise<void> {
239
+ const session = sessionOverride ?? runnerSessions.get(deviceId);
182
240
  if (!session) return;
183
241
  try {
184
242
  await waitForRunner(session.device, session.port, {
@@ -201,7 +259,9 @@ export async function stopIosRunnerSession(deviceId: string): Promise<void> {
201
259
  await killRunnerProcessTree(session.child.pid, 'SIGKILL');
202
260
  cleanupTempFile(session.xctestrunPath);
203
261
  cleanupTempFile(session.jsonPath);
204
- runnerSessions.delete(deviceId);
262
+ if (runnerSessions.get(deviceId) === session) {
263
+ runnerSessions.delete(deviceId);
264
+ }
205
265
  }
206
266
 
207
267
  async function ensureBooted(udid: string): Promise<void> {
@@ -215,58 +275,70 @@ async function ensureRunnerSession(
215
275
  device: DeviceInfo,
216
276
  options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
217
277
  ): Promise<RunnerSession> {
218
- const existing = runnerSessions.get(device.id);
219
- if (existing) return existing;
278
+ return await withRunnerSessionLock(device.id, async () => {
279
+ const existing = runnerSessions.get(device.id);
280
+ if (existing) {
281
+ if (isRunnerProcessAlive(existing.child.pid)) {
282
+ return existing;
283
+ }
284
+ await stopRunnerSessionInternal(device.id, existing);
285
+ }
220
286
 
221
- await ensureBooted(device.id);
222
- const xctestrun = await ensureXctestrun(device.id, options);
223
- const port = await getFreePort();
224
- const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv(
225
- xctestrun,
226
- { AGENT_DEVICE_RUNNER_PORT: String(port) },
227
- `session-${device.id}-${port}`,
228
- );
229
- const { child, wait: testPromise } = runCmdBackground(
230
- 'xcodebuild',
231
- [
232
- 'test-without-building',
233
- '-only-testing',
234
- 'AgentDeviceRunnerUITests/RunnerTests/testCommand',
235
- '-parallel-testing-enabled',
236
- 'NO',
237
- '-test-timeouts-enabled',
238
- 'NO',
239
- '-maximum-concurrent-test-simulator-destinations',
240
- '1',
241
- '-xctestrun',
287
+ await ensureBootedIfNeeded(device);
288
+ const xctestrun = await ensureXctestrun(device, options);
289
+ const port = await getFreePort();
290
+ const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv(
291
+ xctestrun,
292
+ { AGENT_DEVICE_RUNNER_PORT: String(port) },
293
+ `session-${device.id}-${port}`,
294
+ );
295
+ const { child, wait: testPromise } = runCmdBackground(
296
+ 'xcodebuild',
297
+ [
298
+ 'test-without-building',
299
+ '-only-testing',
300
+ 'AgentDeviceRunnerUITests/RunnerTests/testCommand',
301
+ '-parallel-testing-enabled',
302
+ 'NO',
303
+ '-test-timeouts-enabled',
304
+ 'NO',
305
+ resolveRunnerMaxConcurrentDestinationsFlag(device),
306
+ '1',
307
+ '-xctestrun',
308
+ xctestrunPath,
309
+ '-destination',
310
+ resolveRunnerDestination(device),
311
+ ],
312
+ {
313
+ allowFailure: true,
314
+ env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
315
+ },
316
+ );
317
+ child.stdout?.on('data', (chunk: string) => {
318
+ logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
319
+ });
320
+ child.stderr?.on('data', (chunk: string) => {
321
+ logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
322
+ });
323
+
324
+ const session: RunnerSession = {
325
+ device,
326
+ deviceId: device.id,
327
+ port,
242
328
  xctestrunPath,
243
- '-destination',
244
- `platform=iOS Simulator,id=${device.id}`,
245
- ],
246
- {
247
- allowFailure: true,
248
- env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
249
- },
250
- );
251
- child.stdout?.on('data', (chunk: string) => {
252
- logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
253
- });
254
- child.stderr?.on('data', (chunk: string) => {
255
- logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
329
+ jsonPath,
330
+ testPromise,
331
+ child,
332
+ ready: false,
333
+ };
334
+ runnerSessions.set(device.id, session);
335
+ return session;
256
336
  });
337
+ }
257
338
 
258
- const session: RunnerSession = {
259
- device,
260
- deviceId: device.id,
261
- port,
262
- xctestrunPath,
263
- jsonPath,
264
- testPromise,
265
- child,
266
- ready: false,
267
- };
268
- runnerSessions.set(device.id, session);
269
- return session;
339
+ function isRunnerProcessAlive(pid: number | undefined): boolean {
340
+ if (!pid) return false;
341
+ return isProcessAlive(pid);
270
342
  }
271
343
 
272
344
  async function killRunnerProcessTree(
@@ -289,11 +361,12 @@ async function killRunnerProcessTree(
289
361
 
290
362
 
291
363
  async function ensureXctestrun(
292
- udid: string,
364
+ device: DeviceInfo,
293
365
  options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
294
366
  ): Promise<string> {
295
- const derived = resolveRunnerDerivedPath();
367
+ const derived = resolveRunnerDerivedPath(device.kind);
296
368
  if (shouldCleanDerived()) {
369
+ assertSafeDerivedCleanup(derived);
297
370
  try {
298
371
  fs.rmSync(derived, { recursive: true, force: true });
299
372
  } catch {
@@ -310,6 +383,8 @@ async function ensureXctestrun(
310
383
  throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath });
311
384
  }
312
385
 
386
+ const signingBuildSettings = resolveRunnerSigningBuildSettings(process.env, device.kind === 'device');
387
+ const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : [];
313
388
  try {
314
389
  await runCmdStreaming(
315
390
  'xcodebuild',
@@ -321,12 +396,14 @@ async function ensureXctestrun(
321
396
  'AgentDeviceRunner',
322
397
  '-parallel-testing-enabled',
323
398
  'NO',
324
- '-maximum-concurrent-test-simulator-destinations',
399
+ resolveRunnerMaxConcurrentDestinationsFlag(device),
325
400
  '1',
326
401
  '-destination',
327
- `platform=iOS Simulator,id=${udid}`,
402
+ resolveRunnerBuildDestination(device),
328
403
  '-derivedDataPath',
329
404
  derived,
405
+ ...provisioningArgs,
406
+ ...signingBuildSettings,
330
407
  ],
331
408
  {
332
409
  onStdoutChunk: (chunk) => {
@@ -339,10 +416,12 @@ async function ensureXctestrun(
339
416
  );
340
417
  } catch (err) {
341
418
  const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
419
+ const hint = resolveSigningFailureHint(appErr);
342
420
  throw new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed', {
343
421
  error: appErr.message,
344
422
  details: appErr.details,
345
423
  logPath: options.logPath,
424
+ hint,
346
425
  });
347
426
  }
348
427
 
@@ -353,13 +432,90 @@ async function ensureXctestrun(
353
432
  return built;
354
433
  }
355
434
 
356
- function resolveRunnerDerivedPath(): string {
435
+ function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
357
436
  const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
358
437
  if (override) {
359
438
  return path.resolve(override);
360
439
  }
361
- const base = path.join(os.homedir(), '.agent-device', 'ios-runner');
362
- return path.join(base, 'derived');
440
+ return path.join(RUNNER_DERIVED_ROOT, 'derived', kind);
441
+ }
442
+
443
+ export function resolveRunnerDestination(device: DeviceInfo): string {
444
+ if (device.platform !== 'ios') {
445
+ throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
446
+ }
447
+ if (device.kind === 'simulator') {
448
+ return `platform=iOS Simulator,id=${device.id}`;
449
+ }
450
+ return `platform=iOS,id=${device.id}`;
451
+ }
452
+
453
+ export function resolveRunnerBuildDestination(device: DeviceInfo): string {
454
+ if (device.platform !== 'ios') {
455
+ throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
456
+ }
457
+ if (device.kind === 'simulator') {
458
+ return `platform=iOS Simulator,id=${device.id}`;
459
+ }
460
+ return 'generic/platform=iOS';
461
+ }
462
+
463
+ function ensureBootedIfNeeded(device: DeviceInfo): Promise<void> {
464
+ if (device.kind !== 'simulator') {
465
+ return Promise.resolve();
466
+ }
467
+ return ensureBooted(device.id);
468
+ }
469
+
470
+ function validateRunnerDevice(device: DeviceInfo): void {
471
+ if (device.platform !== 'ios') {
472
+ throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
473
+ }
474
+ if (device.kind !== 'simulator' && device.kind !== 'device') {
475
+ throw new AppError('UNSUPPORTED_OPERATION', `Unsupported iOS device kind for runner: ${device.kind}`);
476
+ }
477
+ }
478
+
479
+ export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string {
480
+ return device.kind === 'device'
481
+ ? '-maximum-concurrent-test-device-destinations'
482
+ : '-maximum-concurrent-test-simulator-destinations';
483
+ }
484
+
485
+ export function resolveRunnerSigningBuildSettings(
486
+ env: NodeJS.ProcessEnv = process.env,
487
+ forDevice = false,
488
+ ): string[] {
489
+ if (!forDevice) {
490
+ return [];
491
+ }
492
+ const teamId = env.AGENT_DEVICE_IOS_TEAM_ID?.trim() || '';
493
+ const configuredIdentity = env.AGENT_DEVICE_IOS_SIGNING_IDENTITY?.trim() || '';
494
+ const profile = env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE?.trim() || '';
495
+ const args = ['CODE_SIGN_STYLE=Automatic'];
496
+ if (teamId) {
497
+ args.push(`DEVELOPMENT_TEAM=${teamId}`);
498
+ }
499
+ if (configuredIdentity) {
500
+ args.push(`CODE_SIGN_IDENTITY=${configuredIdentity}`);
501
+ }
502
+ if (profile) args.push(`PROVISIONING_PROFILE_SPECIFIER=${profile}`);
503
+ return args;
504
+ }
505
+
506
+ function resolveSigningFailureHint(error: AppError): string | undefined {
507
+ const details = error.details ? JSON.stringify(error.details) : '';
508
+ const combined = `${error.message}\n${details}`.toLowerCase();
509
+ if (combined.includes('requires a development team')) {
510
+ return 'Configure signing in Xcode or set AGENT_DEVICE_IOS_TEAM_ID for physical-device runs.';
511
+ }
512
+ if (combined.includes('no profiles for') || combined.includes('provisioning profile')) {
513
+ return 'Install/select a valid iOS provisioning profile, or set AGENT_DEVICE_IOS_PROVISIONING_PROFILE.';
514
+ }
515
+ if (combined.includes('code signing')) {
516
+ return 'Enable Automatic Signing in Xcode or provide AGENT_DEVICE_IOS_TEAM_ID and optional AGENT_DEVICE_IOS_SIGNING_IDENTITY.';
517
+ }
518
+ return undefined;
363
519
  }
364
520
 
365
521
  function findXctestrun(root: string): string | null {
@@ -425,9 +581,54 @@ function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
425
581
  }
426
582
 
427
583
  function shouldCleanDerived(): boolean {
428
- const value = process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED;
429
- if (!value) return false;
430
- return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
584
+ return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED);
585
+ }
586
+
587
+ export function assertSafeDerivedCleanup(
588
+ derivedPath: string,
589
+ env: NodeJS.ProcessEnv = process.env,
590
+ ): void {
591
+ const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
592
+ if (!override) {
593
+ return;
594
+ }
595
+ if (isCleanupOverrideAllowed(env)) {
596
+ return;
597
+ }
598
+ throw new AppError(
599
+ 'COMMAND_FAILED',
600
+ 'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically',
601
+ {
602
+ derivedPath,
603
+ hint: 'Unset AGENT_DEVICE_IOS_CLEAN_DERIVED, or set AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1 if you trust this path.',
604
+ },
605
+ );
606
+ }
607
+
608
+ function isCleanupOverrideAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
609
+ return isEnvTruthy(env.AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN);
610
+ }
611
+
612
+ function buildRunnerConnectError(params: {
613
+ port: number;
614
+ endpoints: string[];
615
+ logPath?: string;
616
+ lastError: unknown;
617
+ }): AppError {
618
+ const { port, endpoints, logPath, lastError } = params;
619
+ const message = 'Runner did not accept connection';
620
+ return new AppError('COMMAND_FAILED', message, {
621
+ port,
622
+ endpoints,
623
+ logPath,
624
+ lastError: lastError ? String(lastError) : undefined,
625
+ reason: classifyBootFailure({
626
+ error: lastError,
627
+ message,
628
+ context: { platform: 'ios', phase: 'connect' },
629
+ }),
630
+ hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
631
+ });
431
632
  }
432
633
 
433
634
  async function waitForRunner(
@@ -437,43 +638,167 @@ async function waitForRunner(
437
638
  logPath?: string,
438
639
  timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
439
640
  ): Promise<Response> {
440
- const start = Date.now();
641
+ const deadline = Deadline.fromTimeoutMs(timeoutMs);
642
+ let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
441
643
  let lastError: unknown = null;
442
- while (Date.now() - start < timeoutMs) {
443
- try {
444
- const response = await fetch(`http://127.0.0.1:${port}/command`, {
445
- method: 'POST',
446
- headers: { 'Content-Type': 'application/json' },
447
- body: JSON.stringify(command),
448
- });
449
- return response;
450
- } catch (err) {
451
- lastError = err;
452
- await new Promise((resolve) => setTimeout(resolve, 100));
644
+ const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS));
645
+ try {
646
+ return await retryWithPolicy(
647
+ async ({ deadline: attemptDeadline }) => {
648
+ if (attemptDeadline?.isExpired()) {
649
+ throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
650
+ port,
651
+ timeoutMs,
652
+ });
653
+ }
654
+ if (device.kind === 'device') {
655
+ endpoints = await resolveRunnerCommandEndpoints(device, port, attemptDeadline?.remainingMs());
656
+ }
657
+ for (const endpoint of endpoints) {
658
+ try {
659
+ const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs;
660
+ if (remainingMs <= 0) {
661
+ throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
662
+ port,
663
+ timeoutMs,
664
+ });
665
+ }
666
+ const response = await fetchWithTimeout(
667
+ endpoint,
668
+ {
669
+ method: 'POST',
670
+ headers: { 'Content-Type': 'application/json' },
671
+ body: JSON.stringify(command),
672
+ },
673
+ Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs),
674
+ );
675
+ return response;
676
+ } catch (err) {
677
+ lastError = err;
678
+ }
679
+ }
680
+ throw new AppError('COMMAND_FAILED', 'Runner endpoint probe failed', {
681
+ port,
682
+ endpoints,
683
+ lastError: lastError ? String(lastError) : undefined,
684
+ });
685
+ },
686
+ {
687
+ maxAttempts,
688
+ baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
689
+ maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
690
+ jitter: 0.2,
691
+ shouldRetry: () => true,
692
+ },
693
+ { deadline, phase: 'ios_runner_connect' },
694
+ );
695
+ } catch (error) {
696
+ if (!lastError) {
697
+ lastError = error;
453
698
  }
454
699
  }
700
+
455
701
  if (device.kind === 'simulator') {
456
- const simResponse = await postCommandViaSimulator(device.id, port, command);
702
+ const remainingMs = deadline.remainingMs();
703
+ if (remainingMs <= 0) {
704
+ throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
705
+ }
706
+ const simResponse = await postCommandViaSimulator(device.id, port, command, remainingMs);
457
707
  return new Response(simResponse.body, { status: simResponse.status });
458
708
  }
459
709
 
460
- throw new AppError('COMMAND_FAILED', 'Runner did not accept connection', {
461
- port,
462
- logPath,
463
- lastError: lastError ? String(lastError) : undefined,
464
- reason: classifyBootFailure({
465
- error: lastError,
466
- message: 'Runner did not accept connection',
467
- context: { platform: 'ios', phase: 'connect' },
468
- }),
469
- hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
470
- });
710
+ throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
711
+ }
712
+
713
+ async function resolveRunnerCommandEndpoints(
714
+ device: DeviceInfo,
715
+ port: number,
716
+ timeoutBudgetMs?: number,
717
+ ): Promise<string[]> {
718
+ const endpoints = [`http://127.0.0.1:${port}/command`];
719
+ if (device.kind !== 'device') {
720
+ return endpoints;
721
+ }
722
+ const tunnelIp = await resolveDeviceTunnelIp(device.id, timeoutBudgetMs);
723
+ if (tunnelIp) {
724
+ endpoints.unshift(`http://[${tunnelIp}]:${port}/command`);
725
+ }
726
+ return endpoints;
727
+ }
728
+
729
+ async function fetchWithTimeout(
730
+ url: string,
731
+ init: RequestInit,
732
+ timeoutMs: number,
733
+ ): Promise<Response> {
734
+ const controller = new AbortController();
735
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
736
+ try {
737
+ return await fetch(url, { ...init, signal: controller.signal });
738
+ } finally {
739
+ clearTimeout(timeout);
740
+ }
741
+ }
742
+
743
+ async function resolveDeviceTunnelIp(deviceId: string, timeoutBudgetMs?: number): Promise<string | null> {
744
+ if (typeof timeoutBudgetMs === 'number' && timeoutBudgetMs <= 0) {
745
+ return null;
746
+ }
747
+ const timeoutMs = typeof timeoutBudgetMs === 'number'
748
+ ? Math.max(1, Math.min(RUNNER_DEVICE_INFO_TIMEOUT_MS, timeoutBudgetMs))
749
+ : RUNNER_DEVICE_INFO_TIMEOUT_MS;
750
+ const jsonPath = path.join(
751
+ os.tmpdir(),
752
+ `agent-device-devicectl-info-${process.pid}-${Date.now()}.json`,
753
+ );
754
+ try {
755
+ const devicectlTimeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
756
+ const result = await runCmd(
757
+ 'xcrun',
758
+ [
759
+ 'devicectl',
760
+ 'device',
761
+ 'info',
762
+ 'details',
763
+ '--device',
764
+ deviceId,
765
+ '--json-output',
766
+ jsonPath,
767
+ '--timeout',
768
+ String(devicectlTimeoutSeconds),
769
+ ],
770
+ { allowFailure: true, timeoutMs },
771
+ );
772
+ if (result.exitCode !== 0 || !fs.existsSync(jsonPath)) {
773
+ return null;
774
+ }
775
+ const payload = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as {
776
+ info?: { outcome?: string };
777
+ result?: {
778
+ connectionProperties?: { tunnelIPAddress?: string };
779
+ device?: { connectionProperties?: { tunnelIPAddress?: string } };
780
+ };
781
+ };
782
+ if (payload.info?.outcome && payload.info.outcome !== 'success') {
783
+ return null;
784
+ }
785
+ const ip = (
786
+ payload.result?.connectionProperties?.tunnelIPAddress
787
+ ?? payload.result?.device?.connectionProperties?.tunnelIPAddress
788
+ )?.trim();
789
+ return ip && ip.length > 0 ? ip : null;
790
+ } catch {
791
+ return null;
792
+ } finally {
793
+ cleanupTempFile(jsonPath);
794
+ }
471
795
  }
472
796
 
473
797
  async function postCommandViaSimulator(
474
798
  udid: string,
475
799
  port: number,
476
800
  command: RunnerCommand,
801
+ timeoutMs: number,
477
802
  ): Promise<{ status: number; body: string }> {
478
803
  const payload = JSON.stringify(command);
479
804
  const result = await runCmd(
@@ -492,7 +817,7 @@ async function postCommandViaSimulator(
492
817
  payload,
493
818
  `http://127.0.0.1:${port}/command`,
494
819
  ],
495
- { allowFailure: true },
820
+ { allowFailure: true, timeoutMs },
496
821
  );
497
822
  const body = result.stdout as string;
498
823
  if (result.exitCode !== 0) {