agent-device 0.4.0 → 0.4.2

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 (52) hide show
  1. package/README.md +20 -12
  2. package/dist/src/797.js +1 -0
  3. package/dist/src/bin.js +40 -29
  4. package/dist/src/daemon.js +21 -17
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
  6. package/package.json +2 -2
  7. package/skills/agent-device/SKILL.md +23 -14
  8. package/skills/agent-device/references/permissions.md +7 -2
  9. package/skills/agent-device/references/session-management.md +5 -1
  10. package/src/__tests__/cli-close.test.ts +155 -0
  11. package/src/__tests__/cli-help.test.ts +102 -0
  12. package/src/cli.ts +68 -22
  13. package/src/core/__tests__/capabilities.test.ts +2 -1
  14. package/src/core/__tests__/dispatch-open.test.ts +25 -0
  15. package/src/core/__tests__/open-target.test.ts +40 -1
  16. package/src/core/capabilities.ts +1 -1
  17. package/src/core/dispatch.ts +22 -0
  18. package/src/core/open-target.ts +14 -0
  19. package/src/daemon/__tests__/device-ready.test.ts +52 -0
  20. package/src/daemon/__tests__/session-store.test.ts +23 -0
  21. package/src/daemon/device-ready.ts +146 -4
  22. package/src/daemon/handlers/__tests__/session.test.ts +477 -0
  23. package/src/daemon/handlers/session.ts +198 -93
  24. package/src/daemon/handlers/snapshot.ts +210 -185
  25. package/src/daemon/session-store.ts +16 -6
  26. package/src/daemon/types.ts +2 -1
  27. package/src/daemon-client.ts +138 -17
  28. package/src/daemon.ts +99 -9
  29. package/src/platforms/android/__tests__/index.test.ts +118 -1
  30. package/src/platforms/android/index.ts +77 -47
  31. package/src/platforms/ios/__tests__/index.test.ts +292 -4
  32. package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
  33. package/src/platforms/ios/apps.ts +358 -0
  34. package/src/platforms/ios/config.ts +28 -0
  35. package/src/platforms/ios/devicectl.ts +134 -0
  36. package/src/platforms/ios/devices.ts +15 -2
  37. package/src/platforms/ios/index.ts +20 -455
  38. package/src/platforms/ios/runner-client.ts +171 -69
  39. package/src/platforms/ios/simulator.ts +164 -0
  40. package/src/utils/__tests__/args.test.ts +66 -2
  41. package/src/utils/__tests__/daemon-client.test.ts +95 -0
  42. package/src/utils/__tests__/keyed-lock.test.ts +55 -0
  43. package/src/utils/__tests__/process-identity.test.ts +33 -0
  44. package/src/utils/args.ts +37 -1
  45. package/src/utils/command-schema.ts +58 -27
  46. package/src/utils/interactors.ts +2 -2
  47. package/src/utils/keyed-lock.ts +14 -0
  48. package/src/utils/process-identity.ts +100 -0
  49. package/src/utils/timeouts.ts +9 -0
  50. package/dist/src/274.js +0 -1
  51. package/src/daemon/__tests__/app-state.test.ts +0 -138
  52. package/src/daemon/app-state.ts +0 -65
@@ -1,455 +1,20 @@
1
- import { runCmd } from '../../utils/exec.ts';
2
- import type { ExecResult } from '../../utils/exec.ts';
3
- import { AppError } from '../../utils/errors.ts';
4
- import type { DeviceInfo } from '../../utils/device.ts';
5
- import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
6
- import { isDeepLinkTarget } from '../../core/open-target.ts';
7
- import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
8
-
9
- const ALIASES: Record<string, string> = {
10
- settings: 'com.apple.Preferences',
11
- };
12
-
13
- const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(
14
- process.env.AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS,
15
- TIMEOUT_PROFILES.ios_boot.totalMs,
16
- 5_000,
17
- );
18
- const IOS_SIMCTL_LIST_TIMEOUT_MS = resolveTimeoutMs(
19
- process.env.AGENT_DEVICE_IOS_SIMCTL_LIST_TIMEOUT_MS,
20
- TIMEOUT_PROFILES.ios_boot.operationMs,
21
- 1_000,
22
- );
23
- const IOS_APP_LAUNCH_TIMEOUT_MS = resolveTimeoutMs(
24
- process.env.AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS,
25
- 30_000,
26
- 5_000,
27
- );
28
- const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
29
-
30
- export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
31
- const trimmed = app.trim();
32
- if (trimmed.includes('.')) return trimmed;
33
-
34
- const alias = ALIASES[trimmed.toLowerCase()];
35
- if (alias) return alias;
36
-
37
- if (device.kind === 'simulator') {
38
- const list = await listSimulatorApps(device);
39
- const matches = list.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase());
40
- if (matches.length === 1) return matches[0].bundleId;
41
- if (matches.length > 1) {
42
- throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches });
43
- }
44
- }
45
-
46
- throw new AppError('APP_NOT_INSTALLED', `No app found matching "${app}"`);
47
- }
48
-
49
- export async function openIosApp(
50
- device: DeviceInfo,
51
- app: string,
52
- options?: { appBundleId?: string },
53
- ): Promise<void> {
54
- const deepLinkTarget = app.trim();
55
- if (isDeepLinkTarget(deepLinkTarget)) {
56
- if (device.kind !== 'simulator') {
57
- throw new AppError('UNSUPPORTED_OPERATION', 'Deep link open is only supported on iOS simulators');
58
- }
59
- await ensureBootedSimulator(device);
60
- await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
61
- await runCmd('xcrun', ['simctl', 'openurl', device.id, deepLinkTarget]);
62
- return;
63
- }
64
- const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app));
65
- if (device.kind === 'simulator') {
66
- await ensureBootedSimulator(device);
67
- await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
68
- const launchDeadline = Deadline.fromTimeoutMs(IOS_APP_LAUNCH_TIMEOUT_MS);
69
- await retryWithPolicy(
70
- async ({ deadline: attemptDeadline }) => {
71
- if (attemptDeadline?.isExpired()) {
72
- throw new AppError('COMMAND_FAILED', 'App launch deadline exceeded', {
73
- timeoutMs: IOS_APP_LAUNCH_TIMEOUT_MS,
74
- });
75
- }
76
- const result = await runCmd('xcrun', ['simctl', 'launch', device.id, bundleId], {
77
- allowFailure: true,
78
- });
79
- if (result.exitCode === 0) return;
80
- throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, {
81
- cmd: 'xcrun',
82
- args: ['simctl', 'launch', device.id, bundleId],
83
- stdout: result.stdout,
84
- stderr: result.stderr,
85
- exitCode: result.exitCode,
86
- });
87
- },
88
- {
89
- maxAttempts: 30,
90
- baseDelayMs: 1_000,
91
- maxDelayMs: 5_000,
92
- jitter: 0.2,
93
- shouldRetry: isTransientSimulatorLaunchFailure,
94
- },
95
- { deadline: launchDeadline },
96
- );
97
- return;
98
- }
99
- await runCmd('xcrun', [
100
- 'devicectl',
101
- 'device',
102
- 'process',
103
- 'launch',
104
- '--device',
105
- device.id,
106
- bundleId,
107
- ]);
108
- }
109
-
110
- export async function openIosDevice(device: DeviceInfo): Promise<void> {
111
- if (device.kind !== 'simulator') return;
112
- const state = await getSimulatorState(device.id);
113
- if (state === 'Booted') return;
114
- await ensureBootedSimulator(device);
115
- await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
116
- }
117
-
118
- export async function closeIosApp(device: DeviceInfo, app: string): Promise<void> {
119
- const bundleId = await resolveIosApp(device, app);
120
- if (device.kind === 'simulator') {
121
- await ensureBootedSimulator(device);
122
- const result = await runCmd('xcrun', ['simctl', 'terminate', device.id, bundleId], {
123
- allowFailure: true,
124
- });
125
- if (result.exitCode !== 0) {
126
- const stderr = result.stderr.toLowerCase();
127
- if (stderr.includes('found nothing to terminate')) return;
128
- throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, {
129
- cmd: 'xcrun',
130
- args: ['simctl', 'terminate', device.id, bundleId],
131
- stdout: result.stdout,
132
- stderr: result.stderr,
133
- exitCode: result.exitCode,
134
- });
135
- }
136
- return;
137
- }
138
- await runCmd('xcrun', [
139
- 'devicectl',
140
- 'device',
141
- 'process',
142
- 'terminate',
143
- '--device',
144
- device.id,
145
- bundleId,
146
- ]);
147
- }
148
-
149
- export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundleId: string }> {
150
- ensureSimulator(device, 'reinstall');
151
- const bundleId = await resolveIosApp(device, app);
152
- await ensureBootedSimulator(device);
153
- const result = await runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId], {
154
- allowFailure: true,
155
- });
156
- if (result.exitCode !== 0) {
157
- const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
158
- if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) {
159
- throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, {
160
- stdout: result.stdout,
161
- stderr: result.stderr,
162
- exitCode: result.exitCode,
163
- });
164
- }
165
- }
166
- return { bundleId };
167
- }
168
-
169
- export async function installIosApp(device: DeviceInfo, appPath: string): Promise<void> {
170
- ensureSimulator(device, 'reinstall');
171
- await ensureBootedSimulator(device);
172
- await runCmd('xcrun', ['simctl', 'install', device.id, appPath]);
173
- }
174
-
175
- export async function reinstallIosApp(
176
- device: DeviceInfo,
177
- app: string,
178
- appPath: string,
179
- ): Promise<{ bundleId: string }> {
180
- const { bundleId } = await uninstallIosApp(device, app);
181
- await installIosApp(device, appPath);
182
- return { bundleId };
183
- }
184
-
185
- export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
186
- if (device.kind === 'simulator') {
187
- await ensureBootedSimulator(device);
188
- await runCmd('xcrun', ['simctl', 'io', device.id, 'screenshot', outPath]);
189
- return;
190
- }
191
- await runCmd('xcrun', ['devicectl', 'device', 'screenshot', '--device', device.id, outPath]);
192
- }
193
-
194
- export async function setIosSetting(
195
- device: DeviceInfo,
196
- setting: string,
197
- state: string,
198
- appBundleId?: string,
199
- ): Promise<void> {
200
- ensureSimulator(device, 'settings');
201
- await ensureBootedSimulator(device);
202
- const normalized = setting.toLowerCase();
203
- const enabled = parseSettingState(state);
204
- switch (normalized) {
205
- case 'wifi': {
206
- const mode = enabled ? 'active' : 'failed';
207
- await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'override', '--wifiMode', mode]);
208
- return;
209
- }
210
- case 'airplane': {
211
- if (enabled) {
212
- await runCmd('xcrun', [
213
- 'simctl',
214
- 'status_bar',
215
- device.id,
216
- 'override',
217
- '--dataNetwork',
218
- 'hide',
219
- '--wifiMode',
220
- 'failed',
221
- '--wifiBars',
222
- '0',
223
- '--cellularMode',
224
- 'failed',
225
- '--cellularBars',
226
- '0',
227
- '--operatorName',
228
- '',
229
- ]);
230
- } else {
231
- await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'clear']);
232
- }
233
- return;
234
- }
235
- case 'location': {
236
- if (!appBundleId) {
237
- throw new AppError('INVALID_ARGS', 'location setting requires an active app in session');
238
- }
239
- const action = enabled ? 'grant' : 'revoke';
240
- await runCmd('xcrun', ['simctl', 'privacy', device.id, action, 'location', appBundleId]);
241
- return;
242
- }
243
- default:
244
- throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`);
245
- }
246
- }
247
-
248
- function ensureSimulator(device: DeviceInfo, command: string): void {
249
- if (device.kind !== 'simulator') {
250
- throw new AppError(
251
- 'UNSUPPORTED_OPERATION',
252
- `${command} is only supported on iOS simulators`,
253
- );
254
- }
255
- }
256
-
257
- function parseSettingState(state: string): boolean {
258
- const normalized = state.toLowerCase();
259
- if (normalized === 'on' || normalized === 'true' || normalized === '1') return true;
260
- if (normalized === 'off' || normalized === 'false' || normalized === '0') return false;
261
- throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
262
- }
263
-
264
- function isTransientSimulatorLaunchFailure(error: unknown): boolean {
265
- if (!(error instanceof AppError)) return false;
266
- if (error.code !== 'COMMAND_FAILED') return false;
267
- const details = (error.details ?? {}) as { exitCode?: number; stderr?: unknown };
268
- if (details.exitCode !== 4) return false;
269
- const stderr = String(details.stderr ?? '').toLowerCase();
270
- return (
271
- stderr.includes('fbsopenapplicationserviceerrordomain') &&
272
- stderr.includes('the request to open')
273
- );
274
- }
275
-
276
- export async function listSimulatorApps(
277
- device: DeviceInfo,
278
- ): Promise<{ bundleId: string; name: string }[]> {
279
- const result = await runCmd('xcrun', ['simctl', 'listapps', device.id], { allowFailure: true });
280
- const stdout = result.stdout as string;
281
- const trimmed = stdout.trim();
282
- if (!trimmed) return [];
283
- let parsed: Record<string, { CFBundleDisplayName?: string; CFBundleName?: string }> | null = null;
284
- if (trimmed.startsWith('{')) {
285
- try {
286
- parsed = JSON.parse(trimmed) as Record<
287
- string,
288
- { CFBundleDisplayName?: string; CFBundleName?: string }
289
- >;
290
- } catch {
291
- parsed = null;
292
- }
293
- }
294
- if (!parsed && trimmed.startsWith('{')) {
295
- try {
296
- const converted = await runCmd('plutil', ['-convert', 'json', '-o', '-', '-'], {
297
- allowFailure: true,
298
- stdin: trimmed,
299
- });
300
- if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) {
301
- parsed = JSON.parse(converted.stdout) as Record<
302
- string,
303
- { CFBundleDisplayName?: string; CFBundleName?: string }
304
- >;
305
- }
306
- } catch {
307
- parsed = null;
308
- }
309
- }
310
- if (!parsed) return [];
311
- return Object.entries(parsed).map(([bundleId, info]) => ({
312
- bundleId,
313
- name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId,
314
- }));
315
- }
316
-
317
- export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
318
- if (device.kind !== 'simulator') return;
319
- const state = await getSimulatorState(device.id);
320
- if (state === 'Booted') return;
321
- const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
322
- let bootResult: ExecResult | undefined;
323
- let bootStatusResult: ExecResult | undefined;
324
- try {
325
- await retryWithPolicy(
326
- async ({ deadline: attemptDeadline }) => {
327
- if (attemptDeadline?.isExpired()) {
328
- throw new AppError('COMMAND_FAILED', 'iOS simulator boot deadline exceeded', {
329
- timeoutMs: IOS_BOOT_TIMEOUT_MS,
330
- });
331
- }
332
- const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? IOS_BOOT_TIMEOUT_MS);
333
- bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], {
334
- allowFailure: true,
335
- timeoutMs: remainingMs,
336
- });
337
- const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase();
338
- const bootAlreadyDone =
339
- bootOutput.includes('already booted') || bootOutput.includes('current state: booted');
340
- if (bootResult.exitCode !== 0 && !bootAlreadyDone) {
341
- throw new AppError('COMMAND_FAILED', 'simctl boot failed', {
342
- stdout: bootResult.stdout,
343
- stderr: bootResult.stderr,
344
- exitCode: bootResult.exitCode,
345
- });
346
- }
347
- bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], {
348
- allowFailure: true,
349
- timeoutMs: remainingMs,
350
- });
351
- if (bootStatusResult.exitCode !== 0) {
352
- throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', {
353
- stdout: bootStatusResult.stdout,
354
- stderr: bootStatusResult.stderr,
355
- exitCode: bootStatusResult.exitCode,
356
- });
357
- }
358
- const nextState = await getSimulatorState(device.id);
359
- if (nextState !== 'Booted') {
360
- throw new AppError('COMMAND_FAILED', 'Simulator is still booting', {
361
- state: nextState,
362
- });
363
- }
364
- },
365
- {
366
- maxAttempts: 3,
367
- baseDelayMs: 500,
368
- maxDelayMs: 2000,
369
- jitter: 0.2,
370
- shouldRetry: (error) => {
371
- const reason = classifyBootFailure({
372
- error,
373
- stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
374
- stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
375
- context: { platform: 'ios', phase: 'boot' },
376
- });
377
- return reason !== 'IOS_BOOT_TIMEOUT' && reason !== 'CI_RESOURCE_STARVATION_SUSPECTED';
378
- },
379
- },
380
- {
381
- deadline,
382
- phase: 'boot',
383
- classifyReason: (error) =>
384
- classifyBootFailure({
385
- error,
386
- stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
387
- stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
388
- context: { platform: 'ios', phase: 'boot' },
389
- }),
390
- onEvent: (event: RetryTelemetryEvent) => {
391
- if (!RETRY_LOGS_ENABLED) return;
392
- process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
393
- },
394
- },
395
- );
396
- } catch (error) {
397
- const bootStdout = bootResult?.stdout;
398
- const bootStderr = bootResult?.stderr;
399
- const bootExitCode = bootResult?.exitCode;
400
- const bootstatusStdout = bootStatusResult?.stdout;
401
- const bootstatusStderr = bootStatusResult?.stderr;
402
- const bootstatusExitCode = bootStatusResult?.exitCode;
403
- const reason = classifyBootFailure({
404
- error,
405
- stdout: bootstatusStdout ?? bootStdout,
406
- stderr: bootstatusStderr ?? bootStderr,
407
- context: { platform: 'ios', phase: 'boot' },
408
- });
409
- throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', {
410
- platform: 'ios',
411
- deviceId: device.id,
412
- timeoutMs: IOS_BOOT_TIMEOUT_MS,
413
- elapsedMs: deadline.elapsedMs(),
414
- reason,
415
- hint: bootFailureHint(reason),
416
- boot: bootResult
417
- ? { exitCode: bootExitCode, stdout: bootStdout, stderr: bootStderr }
418
- : undefined,
419
- bootstatus: bootStatusResult
420
- ? {
421
- exitCode: bootstatusExitCode,
422
- stdout: bootstatusStdout,
423
- stderr: bootstatusStderr,
424
- }
425
- : undefined,
426
- });
427
- }
428
- }
429
-
430
- async function getSimulatorState(udid: string): Promise<string | null> {
431
- const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], {
432
- allowFailure: true,
433
- timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS,
434
- });
435
- if (result.exitCode !== 0) return null;
436
- try {
437
- const payload = JSON.parse(result.stdout as string) as {
438
- devices: Record<string, { udid: string; state: string }[]>;
439
- };
440
- for (const runtime of Object.values(payload.devices ?? {})) {
441
- const match = runtime.find((d) => d.udid === udid);
442
- if (match) return match.state;
443
- }
444
- } catch {
445
- return null;
446
- }
447
- return null;
448
- }
449
-
450
- function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
451
- if (!raw) return fallback;
452
- const parsed = Number(raw);
453
- if (!Number.isFinite(parsed)) return fallback;
454
- return Math.max(min, Math.floor(parsed));
455
- }
1
+ export {
2
+ closeIosApp,
3
+ installIosApp,
4
+ listIosApps,
5
+ listSimulatorApps,
6
+ openIosApp,
7
+ openIosDevice,
8
+ reinstallIosApp,
9
+ resolveIosApp,
10
+ screenshotIos,
11
+ setIosSetting,
12
+ uninstallIosApp,
13
+ } from './apps.ts';
14
+
15
+ export { ensureBootedSimulator } from './simulator.ts';
16
+
17
+ export {
18
+ parseIosDeviceAppsPayload,
19
+ type IosAppInfo,
20
+ } from './devicectl.ts';