@expo/build-tools 24.3.0 → 24.4.0

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.
@@ -0,0 +1,537 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.GuardLogTailer = exports.GuardEventRelay = exports.LOCAL_EGRESS_GUARD_LOG_PATH = exports.EGRESS_GUARD_MODE_ENV = exports.EGRESS_GUARD_LOG_ENV = exports.EGRESS_GUARD_CHECK_FILE = exports.EGRESS_GUARD_LIBRARY_FILE = void 0;
7
+ exports.buildGuardLaunchdEnvironment = buildGuardLaunchdEnvironment;
8
+ exports.parseGuardLogLine = parseGuardLogLine;
9
+ exports.resolveEgressGuardLibraryAsync = resolveEgressGuardLibraryAsync;
10
+ exports.resolveEgressGuardCheckAsync = resolveEgressGuardCheckAsync;
11
+ exports.resolveLocalEgressBootEnvironmentAsync = resolveLocalEgressBootEnvironmentAsync;
12
+ exports.installLocalEgressGuardAsync = installLocalEgressGuardAsync;
13
+ exports.parseGuardCoverage = parseGuardCoverage;
14
+ exports.mergeGuardCoverageSamples = mergeGuardCoverageSamples;
15
+ exports.reportLocalEgressGuardCoverageAsync = reportLocalEgressGuardCoverageAsync;
16
+ exports.verifyLocalEgressGuardAsync = verifyLocalEgressGuardAsync;
17
+ exports.rebindLocalEgressGuardRelays = rebindLocalEgressGuardRelays;
18
+ exports.stopLocalEgressGuardRelaysAsync = stopLocalEgressGuardRelaysAsync;
19
+ const eas_build_job_1 = require("@expo/eas-build-job");
20
+ const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
21
+ const node_fs_1 = __importDefault(require("node:fs"));
22
+ const node_os_1 = __importDefault(require("node:os"));
23
+ const node_path_1 = __importDefault(require("node:path"));
24
+ const IosSimulatorUtils_1 = require("../../utils/IosSimulatorUtils");
25
+ const localEgress_1 = require("./localEgress");
26
+ /**
27
+ * Worker side of the local egress guard: a dylib injected into every process
28
+ * the simulator launches, which refuses connections that do not go to
29
+ * loopback (where the proxy and the `--egress-allow` forwards live) and
30
+ * records one event per destination per process. This module installs it
31
+ * through the simulator's launchd environment and relays its events into the
32
+ * session log. See resources/egress-guard/README.md.
33
+ */
34
+ exports.EGRESS_GUARD_LIBRARY_FILE = 'egress-guard.dylib';
35
+ exports.EGRESS_GUARD_CHECK_FILE = 'egress-guard-check';
36
+ exports.EGRESS_GUARD_LOG_ENV = 'EAS_EGRESS_GUARD_LOG';
37
+ exports.EGRESS_GUARD_MODE_ENV = 'EAS_EGRESS_GUARD_MODE';
38
+ exports.LOCAL_EGRESS_GUARD_LOG_PATH = node_path_1.default.join(node_os_1.default.tmpdir(), 'eas-local-egress-guard.log');
39
+ const GUARD_EVENT_PREFIX = 'eas-egress-guard';
40
+ /** Written once by a process whose per-process table of destinations is full; see policy.h. */
41
+ const GUARD_OVERFLOW_FUNCTION = 'overflow';
42
+ const GUARD_RELAY_LOG_LIMIT = 200;
43
+ const GUARD_TAIL_INTERVAL_MS = 1_000;
44
+ function buildGuardLaunchdEnvironment({ libraryPath, logPath, mode, }) {
45
+ return {
46
+ DYLD_INSERT_LIBRARIES: libraryPath,
47
+ [exports.EGRESS_GUARD_LOG_ENV]: logPath,
48
+ [exports.EGRESS_GUARD_MODE_ENV]: mode,
49
+ };
50
+ }
51
+ /** One tab-separated line written by the guard; see policy.h for the format. */
52
+ function parseGuardLogLine(line) {
53
+ const fields = line.split('\t');
54
+ if (fields.length < 7 || fields[0] !== GUARD_EVENT_PREFIX) {
55
+ return null;
56
+ }
57
+ const [, process, pidText, fn, action, peer, callerText] = fields;
58
+ const pid = Number(pidText);
59
+ if (!Number.isInteger(pid) || (action !== 'blocked' && action !== 'logged') || !fn || !peer) {
60
+ return null;
61
+ }
62
+ return {
63
+ process,
64
+ pid,
65
+ function: fn,
66
+ action,
67
+ peer,
68
+ callers: callerText ? callerText.split(',').filter(Boolean) : [],
69
+ };
70
+ }
71
+ const PACKAGED_BIN_DIR = node_path_1.default.join(__dirname, '..', '..', '..', 'bin');
72
+ async function resolvePackagedFileAsync(binDir, file) {
73
+ const filePath = node_path_1.default.join(binDir, file);
74
+ try {
75
+ await node_fs_1.default.promises.access(filePath);
76
+ return filePath;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** The packaged library, next to the compiled package like record-sim. */
83
+ async function resolveEgressGuardLibraryAsync(binDir = PACKAGED_BIN_DIR) {
84
+ return await resolvePackagedFileAsync(binDir, exports.EGRESS_GUARD_LIBRARY_FILE);
85
+ }
86
+ /** The packaged self-check binary, built alongside the library. */
87
+ async function resolveEgressGuardCheckAsync(binDir = PACKAGED_BIN_DIR) {
88
+ return await resolvePackagedFileAsync(binDir, exports.EGRESS_GUARD_CHECK_FILE);
89
+ }
90
+ /**
91
+ * Turns guard events into session log lines: one line the first time a
92
+ * process reaches a destination through a given call, counts after that, and
93
+ * a summary at the end.
94
+ */
95
+ class GuardEventRelay {
96
+ limit;
97
+ seen = new Set();
98
+ peers = new Set();
99
+ processes = new Set();
100
+ blocked = 0;
101
+ logged = 0;
102
+ suppressed = 0;
103
+ logger;
104
+ constructor(logger, limit = GUARD_RELAY_LOG_LIMIT) {
105
+ this.limit = limit;
106
+ this.logger = logger;
107
+ }
108
+ /**
109
+ * Log through a different step's logger from now on. Refusals happen for
110
+ * the life of the session, so they belong to the step that runs the
111
+ * session, not the one that booted the simulator minutes earlier.
112
+ */
113
+ setLogger(logger) {
114
+ this.logger = logger;
115
+ }
116
+ handle(event) {
117
+ if (event.process === exports.EGRESS_GUARD_CHECK_FILE) {
118
+ // The self-check deliberately trips the guard once; not a bypass.
119
+ return;
120
+ }
121
+ if (event.function === GUARD_OVERFLOW_FUNCTION) {
122
+ const verb = event.action === 'blocked' ? 'refused' : 'observed';
123
+ this.logger.info(`Local egress guard: ${event.process} (pid ${event.pid}) reached the limit of ${event.peer} listed per process; further distinct destinations from it are ${verb} but not listed.`);
124
+ return;
125
+ }
126
+ if (event.action === 'blocked') {
127
+ this.blocked++;
128
+ }
129
+ else {
130
+ this.logged++;
131
+ }
132
+ this.peers.add(event.peer);
133
+ this.processes.add(event.process);
134
+ const key = `${event.process}|${event.function}|${event.peer}`;
135
+ if (this.seen.has(key)) {
136
+ return;
137
+ }
138
+ this.seen.add(key);
139
+ if (this.seen.size > this.limit) {
140
+ this.suppressed++;
141
+ return;
142
+ }
143
+ const verb = event.action === 'blocked' ? 'refused' : 'observed';
144
+ const callers = event.callers.length ? `; callers: ${event.callers.join(', ')}` : '';
145
+ this.logger.info(`Local egress guard: ${verb} ${event.function} from ${event.process} (pid ${event.pid}) to ${event.peer}${callers}`);
146
+ }
147
+ summary() {
148
+ return {
149
+ blocked: this.blocked,
150
+ logged: this.logged,
151
+ distinct: this.peers.size,
152
+ suppressed: this.suppressed,
153
+ };
154
+ }
155
+ logSummary() {
156
+ const { blocked, logged, distinct, suppressed } = this.summary();
157
+ const observed = logged ? ` and observed ${logged} more without refusing` : '';
158
+ const dropped = suppressed
159
+ ? ` ${suppressed} further distinct destination(s) were not logged individually.`
160
+ : '';
161
+ this.logger.info(`Local egress guard: refused ${blocked} connection attempt(s) to ${distinct} distinct destination(s) from ${this.processes.size} process(es)${observed}.${dropped}`);
162
+ }
163
+ }
164
+ exports.GuardEventRelay = GuardEventRelay;
165
+ /**
166
+ * Polls a file the simulator processes append to and delivers whole lines.
167
+ * Tolerates the file not existing yet, partial trailing lines, and truncation.
168
+ */
169
+ class GuardLogTailer {
170
+ path;
171
+ onLine;
172
+ intervalMs;
173
+ offset = 0;
174
+ partial = '';
175
+ timer;
176
+ reading = Promise.resolve();
177
+ constructor({ path: filePath, onLine, intervalMs = GUARD_TAIL_INTERVAL_MS, }) {
178
+ this.path = filePath;
179
+ this.onLine = onLine;
180
+ this.intervalMs = intervalMs;
181
+ }
182
+ start() {
183
+ this.timer = setInterval(() => {
184
+ this.reading = this.reading.then(() => this.readAsync()).catch(() => { });
185
+ }, this.intervalMs);
186
+ this.timer.unref();
187
+ }
188
+ async stopAsync() {
189
+ if (this.timer) {
190
+ clearInterval(this.timer);
191
+ this.timer = undefined;
192
+ }
193
+ await this.reading.catch(() => { });
194
+ await this.readAsync().catch(() => { });
195
+ }
196
+ async readAsync() {
197
+ let handle;
198
+ try {
199
+ handle = await node_fs_1.default.promises.open(this.path, 'r');
200
+ }
201
+ catch (err) {
202
+ if (err.code === 'ENOENT') {
203
+ return;
204
+ }
205
+ throw err;
206
+ }
207
+ try {
208
+ const { size } = await handle.stat();
209
+ if (size < this.offset) {
210
+ // Truncated or replaced; start over.
211
+ this.offset = 0;
212
+ this.partial = '';
213
+ }
214
+ if (size === this.offset) {
215
+ return;
216
+ }
217
+ const buffer = new Uint8Array(size - this.offset);
218
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, this.offset);
219
+ this.offset += bytesRead;
220
+ const text = this.partial + Buffer.from(buffer.buffer, 0, bytesRead).toString('utf8');
221
+ const lines = text.split('\n');
222
+ this.partial = lines.pop() ?? '';
223
+ for (const line of lines) {
224
+ if (line.length > 0) {
225
+ this.onLine(line);
226
+ }
227
+ }
228
+ }
229
+ finally {
230
+ await handle.close();
231
+ }
232
+ }
233
+ }
234
+ exports.GuardLogTailer = GuardLogTailer;
235
+ /**
236
+ * The environment the simulator's launchd must have from its first process:
237
+ * the guard and the proxy variables. Pass it to `IosSimulatorUtils.bootAsync`,
238
+ * which hands it to launchd before anything is spawned; `launchctl setenv`
239
+ * after boot only reaches later processes. Returns null when no local egress
240
+ * session is active. Throws when the guard library is not packaged, since a
241
+ * local egress session without it would silently leak.
242
+ */
243
+ async function resolveLocalEgressBootEnvironmentAsync({ handoffPath = localEgress_1.LOCAL_EGRESS_HANDOFF_PATH, libraryPath, logPath = exports.LOCAL_EGRESS_GUARD_LOG_PATH, mode = 'block', } = {}) {
244
+ const handoff = await (0, localEgress_1.readLocalEgressHandoffAsync)(handoffPath);
245
+ if (!handoff) {
246
+ return null;
247
+ }
248
+ const resolvedLibrary = libraryPath === undefined ? await resolveEgressGuardLibraryAsync() : libraryPath;
249
+ if (!resolvedLibrary) {
250
+ throw new eas_build_job_1.SystemError('The local egress guard library is not available on this device host, so this local egress session ' +
251
+ 'cannot guarantee that connections bypassing the system proxy are refused. The device host image is ' +
252
+ 'missing bin/egress-guard.dylib; this is a service problem, please contact support.');
253
+ }
254
+ return {
255
+ ...buildGuardLaunchdEnvironment({ libraryPath: resolvedLibrary, logPath, mode }),
256
+ ...(0, localEgress_1.buildLocalEgressSimulatorEnvironment)(handoff.port),
257
+ };
258
+ }
259
+ const activeRelays = new Map();
260
+ /**
261
+ * Install the guard into a simulator when a local egress session is active,
262
+ * and start relaying its events into the session log. Returns false when
263
+ * there is no local egress session. Throws when the library is not packaged
264
+ * or launchd could not be configured: a local egress session without the
265
+ * guard would silently leak, so it must not start. Only an unwritable event
266
+ * log is a warning, since refusals still happen and only reporting is lost.
267
+ *
268
+ * Call this as soon as `simctl boot` returns: launchd is up and nothing else
269
+ * has started, so every process the boot spawns inherits the guard. Verify
270
+ * with `verifyLocalEgressGuardAsync` once boot completes.
271
+ */
272
+ async function installLocalEgressGuardAsync({ udid, env, logger, handoffPath = localEgress_1.LOCAL_EGRESS_HANDOFF_PATH, libraryPath, logPath = exports.LOCAL_EGRESS_GUARD_LOG_PATH, mode = 'block', tailIntervalMs, }) {
273
+ let handoff;
274
+ try {
275
+ handoff = await (0, localEgress_1.readLocalEgressHandoffAsync)(handoffPath);
276
+ }
277
+ catch (err) {
278
+ logger.warn({ err }, 'Local egress guard: could not read the local egress handoff, so the guard was not installed. ' +
279
+ 'Connections that bypass the proxy will exit from this worker.');
280
+ return false;
281
+ }
282
+ if (!handoff) {
283
+ return false;
284
+ }
285
+ const resolvedLibrary = libraryPath === undefined ? await resolveEgressGuardLibraryAsync() : libraryPath;
286
+ if (!resolvedLibrary) {
287
+ throw new eas_build_job_1.SystemError('The local egress guard library is not available on this device host, so this local egress session ' +
288
+ 'cannot guarantee that connections bypassing the system proxy are refused. The device host image is ' +
289
+ 'missing bin/egress-guard.dylib; this is a service problem, please contact support.');
290
+ }
291
+ await logAlreadyRunningProcessesAsync({ env, logger });
292
+ let logWritable = true;
293
+ try {
294
+ await node_fs_1.default.promises.mkdir(node_path_1.default.dirname(logPath), { recursive: true });
295
+ await node_fs_1.default.promises.appendFile(logPath, '');
296
+ }
297
+ catch (err) {
298
+ logWritable = false;
299
+ logger.warn({ err }, `Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`);
300
+ }
301
+ try {
302
+ await IosSimulatorUtils_1.IosSimulatorUtils.setLaunchdEnvironmentAsync({
303
+ udid,
304
+ env,
305
+ variables: buildGuardLaunchdEnvironment({ libraryPath: resolvedLibrary, logPath, mode }),
306
+ });
307
+ }
308
+ catch (err) {
309
+ throw new eas_build_job_1.SystemError('Could not install the local egress guard in the Simulator (launchctl setenv failed), so this local ' +
310
+ 'egress session cannot guarantee that connections bypassing the system proxy are refused. Retry the ' +
311
+ 'session; if it keeps failing, please contact support.', { cause: err });
312
+ }
313
+ if (logWritable && !activeRelays.has(logPath)) {
314
+ const relay = new GuardEventRelay(logger);
315
+ const tailer = new GuardLogTailer({
316
+ path: logPath,
317
+ intervalMs: tailIntervalMs,
318
+ onLine: line => {
319
+ const event = parseGuardLogLine(line);
320
+ if (event) {
321
+ relay.handle(event);
322
+ }
323
+ },
324
+ });
325
+ tailer.start();
326
+ activeRelays.set(logPath, { tailer, relay });
327
+ }
328
+ logger.info(`Local egress guard installed in the Simulator (mode ${mode}): connections that bypass the system proxy are ${mode === 'block' ? 'refused' : 'observed'} in the process that makes them and reported here as they happen.`);
329
+ return true;
330
+ }
331
+ /**
332
+ * Processes the simulator already runs when the guard is installed never get
333
+ * it. Right after `simctl boot` that is nothing; later it is SpringBoard and
334
+ * the early daemons, which follow the system proxy anyway. Log them so the
335
+ * uncovered set is visible rather than assumed.
336
+ */
337
+ async function logAlreadyRunningProcessesAsync({ env, logger, }) {
338
+ let psOutput = '';
339
+ try {
340
+ psOutput = (await (0, turtle_spawn_1.default)('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' })).stdout;
341
+ }
342
+ catch {
343
+ return;
344
+ }
345
+ const pids = new Set((0, localEgress_1.collectSimulatorProcessIds)(psOutput));
346
+ const names = new Set();
347
+ for (const line of psOutput.split('\n')) {
348
+ const match = /^\s*(\d+)\s+\d+\s+(\S.*)$/.exec(line);
349
+ if (match && pids.has(Number(match[1]))) {
350
+ names.add(node_path_1.default.basename(match[2].trim()));
351
+ }
352
+ }
353
+ if (names.size === 0) {
354
+ logger.info('Local egress guard: no simulator process was running before the guard was installed.');
355
+ return;
356
+ }
357
+ const listed = [...names].sort();
358
+ const shown = listed.slice(0, 20).join(', ') + (listed.length > 20 ? `, and ${listed.length - 20} more` : '');
359
+ logger.info(`Local egress guard: ${names.size} simulator process(es) were already running before the guard was installed and are not covered by it: ${shown}.`);
360
+ }
361
+ /**
362
+ * launchd's trampoline exists for milliseconds between fork and exec of the
363
+ * real service, with nothing mapped yet; it never makes a connection itself.
364
+ */
365
+ const COVERAGE_IGNORED_PROCESSES = new Set(['xpcproxy_sim']);
366
+ /**
367
+ * Which simulator processes have the guard library mapped, from
368
+ * `ps -axo pid=,ppid=,comm=` and `lsof -nP -a -p <pids> -d txt -F pn` output.
369
+ * A process is covered when any of its mapped images is the guard library.
370
+ */
371
+ function parseGuardCoverage(psOutput, lsofOutput) {
372
+ const simulatorPids = new Set((0, localEgress_1.collectSimulatorProcessIds)(psOutput));
373
+ const commandsByPid = new Map();
374
+ for (const line of psOutput.split('\n')) {
375
+ const match = /^\s*(\d+)\s+\d+\s+(\S.*)$/.exec(line);
376
+ if (match) {
377
+ commandsByPid.set(Number(match[1]), node_path_1.default.basename(match[2].trim()));
378
+ }
379
+ }
380
+ const loaded = new Set();
381
+ let pid = null;
382
+ for (const line of lsofOutput.split('\n')) {
383
+ if (line[0] === 'p') {
384
+ pid = Number(line.slice(1));
385
+ }
386
+ else if (line[0] === 'n' && pid !== null && line.endsWith(exports.EGRESS_GUARD_LIBRARY_FILE)) {
387
+ loaded.add(pid);
388
+ }
389
+ }
390
+ const covered = [];
391
+ const uncovered = [];
392
+ const uncoveredPids = [];
393
+ for (const simulatorPid of simulatorPids) {
394
+ const name = commandsByPid.get(simulatorPid) ?? String(simulatorPid);
395
+ if (COVERAGE_IGNORED_PROCESSES.has(name)) {
396
+ continue;
397
+ }
398
+ if (loaded.has(simulatorPid)) {
399
+ covered.push(name);
400
+ }
401
+ else {
402
+ uncovered.push(name);
403
+ uncoveredPids.push(simulatorPid);
404
+ }
405
+ }
406
+ covered.sort();
407
+ uncovered.sort();
408
+ return { covered, uncovered, uncoveredPids };
409
+ }
410
+ /**
411
+ * A process caught between fork and exec has nothing mapped yet and looks
412
+ * uncovered for an instant. Two samples a moment apart separate those from
413
+ * processes that really run without the guard: only pids uncovered in both
414
+ * count, reported with the later sample's names.
415
+ */
416
+ function mergeGuardCoverageSamples(first, second) {
417
+ const persistent = new Set(first.uncoveredPids.filter(pid => second.uncoveredPids.includes(pid)));
418
+ const uncovered = [];
419
+ const uncoveredPids = [];
420
+ second.uncoveredPids.forEach((pid, index) => {
421
+ if (persistent.has(pid)) {
422
+ uncovered.push(second.uncovered[index]);
423
+ uncoveredPids.push(pid);
424
+ }
425
+ });
426
+ return { covered: second.covered, uncovered, uncoveredPids };
427
+ }
428
+ /**
429
+ * Measure and log guard coverage across the simulator's processes. Observation
430
+ * only: the uncovered set is whatever started before the guard was installed,
431
+ * which is nothing when installation runs right after `simctl boot`.
432
+ */
433
+ async function reportLocalEgressGuardCoverageAsync({ env, logger, sampleIntervalMs = 1_000, }) {
434
+ const first = await sampleGuardCoverageAsync({ env });
435
+ if (!first) {
436
+ return null;
437
+ }
438
+ await new Promise(resolve => setTimeout(resolve, sampleIntervalMs));
439
+ const second = await sampleGuardCoverageAsync({ env });
440
+ const coverage = second ? mergeGuardCoverageSamples(first, second) : first;
441
+ const total = coverage.covered.length + coverage.uncovered.length;
442
+ if (coverage.uncovered.length === 0) {
443
+ logger.info(`Local egress guard coverage: all ${total} simulator process(es) have the guard loaded.`);
444
+ }
445
+ else {
446
+ const shown = coverage.uncovered.slice(0, 20).join(', ') +
447
+ (coverage.uncovered.length > 20 ? `, and ${coverage.uncovered.length - 20} more` : '');
448
+ logger.info(`Local egress guard coverage: ${coverage.covered.length} of ${total} simulator process(es) have the guard loaded. Not covered, started before the guard was installed: ${shown}.`);
449
+ }
450
+ return coverage;
451
+ }
452
+ async function sampleGuardCoverageAsync({ env, }) {
453
+ let psOutput;
454
+ try {
455
+ psOutput = (await (0, turtle_spawn_1.default)('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' })).stdout;
456
+ }
457
+ catch {
458
+ return null;
459
+ }
460
+ const pids = (0, localEgress_1.collectSimulatorProcessIds)(psOutput);
461
+ if (pids.length === 0) {
462
+ return null;
463
+ }
464
+ let lsofOutput;
465
+ try {
466
+ lsofOutput = (await (0, turtle_spawn_1.default)('lsof', ['-nP', '-a', '-p', pids.join(','), '-d', 'txt', '-F', 'pn'], {
467
+ env,
468
+ stdio: 'pipe',
469
+ })).stdout;
470
+ }
471
+ catch (err) {
472
+ // lsof exits 1 when some listed pid has already exited; stdout is still valid.
473
+ const result = err;
474
+ if (result.status !== 1) {
475
+ return null;
476
+ }
477
+ lsofOutput = result.stdout ?? '';
478
+ }
479
+ return parseGuardCoverage(psOutput, lsofOutput);
480
+ }
481
+ /**
482
+ * Run the packaged self-check inside the simulator: it must find the guard
483
+ * loaded in a fresh process and see it behave as `mode` says. Throws when the
484
+ * check binary is missing or the check fails, which fails the session.
485
+ */
486
+ async function verifyLocalEgressGuardAsync({ udid, env, logger, mode = 'block', checkPath, }) {
487
+ const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
488
+ if (!resolvedCheck) {
489
+ throw new eas_build_job_1.SystemError('The local egress guard self-check is not available on this device host, so this session cannot ' +
490
+ 'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
491
+ 'this is a service problem, please contact support.');
492
+ }
493
+ let output;
494
+ try {
495
+ const result = await (0, turtle_spawn_1.default)('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], {
496
+ env,
497
+ stdio: 'pipe',
498
+ });
499
+ output = result.stdout.trim();
500
+ }
501
+ catch (err) {
502
+ const failed = err;
503
+ const detail = [failed.stdout, failed.stderr].filter(Boolean).join('\n').trim();
504
+ throw new eas_build_job_1.SystemError('The local egress guard is not in effect in the Simulator: the self-check run right after boot ' +
505
+ `failed${failed.status != null ? ` (exit ${failed.status})` : ''}. Connections that bypass the ` +
506
+ 'system proxy would leave from the device host, so the session was stopped. Retry the session; if it ' +
507
+ `keeps failing, please contact support.${detail ? ` Self-check output: ${detail}` : ''}`, { cause: err });
508
+ }
509
+ logger.info(`Local egress guard verified in the Simulator: ${output || 'self-check passed'}.`);
510
+ await reportLocalEgressGuardCoverageAsync({ env, logger });
511
+ }
512
+ /**
513
+ * Attribute guard events to the step that runs the session from now on. The
514
+ * relay starts under the simulator boot step's logger; once the session step
515
+ * takes over, its lines should appear under that step in the job log, next
516
+ * to the rest of the session's output, rather than under a step that already
517
+ * finished.
518
+ */
519
+ function rebindLocalEgressGuardRelays(logger) {
520
+ for (const { relay } of activeRelays.values()) {
521
+ relay.setLogger(logger);
522
+ }
523
+ }
524
+ /** Stop relaying and write each relay's summary; called from the session cleanup. */
525
+ async function stopLocalEgressGuardRelaysAsync(logger) {
526
+ const relays = [...activeRelays.values()];
527
+ activeRelays.clear();
528
+ for (const { tailer, relay } of relays) {
529
+ try {
530
+ await tailer.stopAsync();
531
+ }
532
+ catch (err) {
533
+ logger.warn({ err }, 'Local egress guard: could not read the last events from the guard log.');
534
+ }
535
+ relay.logSummary();
536
+ }
537
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withLocalEgressSession = withLocalEgressSession;
4
4
  exports.uploadRemoteSessionConfigWithLocalEgressAsync = uploadRemoteSessionConfigWithLocalEgressAsync;
5
5
  const localEgress_1 = require("./localEgress");
6
+ const localEgressGuard_1 = require("./localEgressGuard");
6
7
  const remoteDeviceRunSession_1 = require("./remoteDeviceRunSession");
7
8
  /** Release the pre-boot egress resources even if controller startup or teardown fails. */
8
9
  function withLocalEgressSession(fn) {
@@ -26,6 +27,9 @@ async function uploadRemoteSessionConfigWithLocalEgressAsync({ env, signal, ...o
26
27
  remoteConfig: { ...options.remoteConfig, ...(0, localEgress_1.buildEgressRemoteConfigFields)(localEgress) },
27
28
  });
28
29
  if (localEgress && !signal?.aborted) {
30
+ // Guard refusals from here on show up under this step in the job log,
31
+ // alongside the monitor's reports, instead of under the boot step.
32
+ (0, localEgressGuard_1.rebindLocalEgressGuardRelays)(options.logger);
29
33
  options.logger.info('Local egress: waiting for the EAS CLI egress client to connect. Proxied HTTP(S) ' +
30
34
  'requests are unavailable until it does.');
31
35
  // The monitor also observes the registered resources' lifetime signal, which
@@ -52,6 +52,29 @@ export declare namespace IosSimulatorUtils {
52
52
  deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
53
53
  env: NodeJS.ProcessEnv;
54
54
  }): Promise<void>;
55
+ /**
56
+ * The UDID for a device name or UDID. A name picks the first available
57
+ * device with that name, as `simctl` itself does.
58
+ */
59
+ export function resolveUdidAsync({ deviceIdentifier, env, }: {
60
+ deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
61
+ env: NodeJS.ProcessEnv;
62
+ }): Promise<IosSimulatorUuid>;
63
+ /**
64
+ * Start booting without waiting for boot to complete; follow with
65
+ * `startAsync` to wait for it. `launchdEnvironment` is handed to the
66
+ * simulator's launchd before it spawns anything: `simctl` forwards every
67
+ * `SIMCTL_CHILD_`-prefixed variable of its own environment to the process
68
+ * it starts, and for `boot` that process is launchd itself. This is the only
69
+ * way to give the first processes of a boot an environment; `launchctl
70
+ * setenv` after boot only reaches processes started later. A device that is
71
+ * already booted keeps its environment.
72
+ */
73
+ export function bootAsync({ deviceIdentifier, env, launchdEnvironment, }: {
74
+ deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
75
+ env: NodeJS.ProcessEnv;
76
+ launchdEnvironment?: Record<string, string>;
77
+ }): Promise<void>;
55
78
  export function startAsync({ deviceIdentifier, env, }: {
56
79
  deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
57
80
  env: NodeJS.ProcessEnv;
@@ -66,6 +89,17 @@ export declare namespace IosSimulatorUtils {
66
89
  udid: IosSimulatorUuid;
67
90
  env: NodeJS.ProcessEnv;
68
91
  }): Promise<void>;
92
+ /**
93
+ * Set environment variables in the Simulator's launchd. Every process that
94
+ * launchd spawns afterwards inherits them: apps launched by SpringBoard
95
+ * (deep links, taps, WebDriverAgent) as well as by `simctl launch`.
96
+ * Processes that are already running keep their environment.
97
+ */
98
+ export function setLaunchdEnvironmentAsync({ udid, env, variables, }: {
99
+ udid: IosSimulatorUuid;
100
+ env: NodeJS.ProcessEnv;
101
+ variables: Record<string, string>;
102
+ }): Promise<void>;
69
103
  export function collectLogsAsync({ deviceIdentifier, env, }: {
70
104
  deviceIdentifier: IosSimulatorName | IosSimulatorUuid;
71
105
  env: NodeJS.ProcessEnv;
@@ -87,6 +87,50 @@ var IosSimulatorUtils;
87
87
  }
88
88
  }
89
89
  IosSimulatorUtils.enableAccessibilitySettingsAsync = enableAccessibilitySettingsAsync;
90
+ const UDID_PATTERN = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
91
+ /**
92
+ * The UDID for a device name or UDID. A name picks the first available
93
+ * device with that name, as `simctl` itself does.
94
+ */
95
+ async function resolveUdidAsync({ deviceIdentifier, env, }) {
96
+ if (UDID_PATTERN.test(deviceIdentifier)) {
97
+ return deviceIdentifier;
98
+ }
99
+ const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
100
+ const device = devices.find(candidate => candidate.name === deviceIdentifier);
101
+ if (!device) {
102
+ throw new eas_build_job_1.UserError('EAS_IOS_SIMULATOR_NOT_FOUND', `No available iOS Simulator is named "${deviceIdentifier}". Run \`xcrun simctl list devices available\` on the device host to see the devices it offers.`);
103
+ }
104
+ return device.udid;
105
+ }
106
+ IosSimulatorUtils.resolveUdidAsync = resolveUdidAsync;
107
+ /**
108
+ * Start booting without waiting for boot to complete; follow with
109
+ * `startAsync` to wait for it. `launchdEnvironment` is handed to the
110
+ * simulator's launchd before it spawns anything: `simctl` forwards every
111
+ * `SIMCTL_CHILD_`-prefixed variable of its own environment to the process
112
+ * it starts, and for `boot` that process is launchd itself. This is the only
113
+ * way to give the first processes of a boot an environment; `launchctl
114
+ * setenv` after boot only reaches processes started later. A device that is
115
+ * already booted keeps its environment.
116
+ */
117
+ async function bootAsync({ deviceIdentifier, env, launchdEnvironment = {}, }) {
118
+ const bootEnv = { ...env };
119
+ for (const [name, value] of Object.entries(launchdEnvironment)) {
120
+ bootEnv[`SIMCTL_CHILD_${name}`] = value;
121
+ }
122
+ try {
123
+ await (0, turtle_spawn_1.default)('xcrun', ['simctl', 'boot', deviceIdentifier], { env: bootEnv, stdio: 'pipe' });
124
+ }
125
+ catch (err) {
126
+ const failed = err;
127
+ if (/current state: Booted/.test(failed.stderr ?? '')) {
128
+ return;
129
+ }
130
+ throw err;
131
+ }
132
+ }
133
+ IosSimulatorUtils.bootAsync = bootAsync;
90
134
  async function startAsync({ deviceIdentifier, env, }) {
91
135
  const bootstatusResult = await (0, turtle_spawn_1.default)('xcrun', ['simctl', 'bootstatus', deviceIdentifier, '-b'], {
92
136
  env,
@@ -154,6 +198,24 @@ var IosSimulatorUtils;
154
198
  throw lastError ?? new eas_build_job_1.SystemError('Unable to disable apsd in the Simulator.');
155
199
  }
156
200
  IosSimulatorUtils.disableApsdAsync = disableApsdAsync;
201
+ /**
202
+ * Set environment variables in the Simulator's launchd. Every process that
203
+ * launchd spawns afterwards inherits them: apps launched by SpringBoard
204
+ * (deep links, taps, WebDriverAgent) as well as by `simctl launch`.
205
+ * Processes that are already running keep their environment.
206
+ */
207
+ async function setLaunchdEnvironmentAsync({ udid, env, variables, }) {
208
+ // One invocation for every variable: each `simctl spawn` costs a few
209
+ // hundred milliseconds on a device host, and this runs in the window
210
+ // between `simctl boot` returning and launchd spawning the boot's
211
+ // processes, which must inherit these.
212
+ const pairs = Object.entries(variables).flat();
213
+ if (pairs.length === 0) {
214
+ return;
215
+ }
216
+ await (0, turtle_spawn_1.default)('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'setenv', ...pairs], { env });
217
+ }
218
+ IosSimulatorUtils.setLaunchdEnvironmentAsync = setLaunchdEnvironmentAsync;
157
219
  async function collectLogsAsync({ deviceIdentifier, env, }) {
158
220
  const outputDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'ios-simulator-logs-'));
159
221
  const outputPath = node_path_1.default.join(outputDir, `${deviceIdentifier}.logarchive`);