@expo/build-tools 24.2.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.
Files changed (29) hide show
  1. package/dist/common/projectSources.js +3 -0
  2. package/dist/steps/easFunctions.js +2 -0
  3. package/dist/steps/functions/startIosSimulator.js +34 -2
  4. package/dist/steps/functions/startLocalEgress.d.ts +4 -3
  5. package/dist/steps/functions/startLocalEgress.js +10 -6
  6. package/dist/steps/functions/startSandbox.d.ts +4 -0
  7. package/dist/steps/functions/startSandbox.js +93 -0
  8. package/dist/steps/utils/localEgress.d.ts +24 -0
  9. package/dist/steps/utils/localEgress.js +59 -1
  10. package/dist/steps/utils/localEgressGuard.d.ts +179 -0
  11. package/dist/steps/utils/localEgressGuard.js +537 -0
  12. package/dist/steps/utils/localEgressSession.js +4 -0
  13. package/dist/steps/utils/sandboxDaemon.d.ts +13 -0
  14. package/dist/steps/utils/sandboxDaemon.js +90 -0
  15. package/dist/utils/IosSimulatorUtils.d.ts +34 -0
  16. package/dist/utils/IosSimulatorUtils.js +62 -0
  17. package/package.json +8 -4
  18. package/resources/egress-guard/README.md +83 -0
  19. package/resources/egress-guard/build.sh +30 -0
  20. package/resources/egress-guard/check.c +112 -0
  21. package/resources/egress-guard/guard.c +274 -0
  22. package/resources/egress-guard/policy.c +164 -0
  23. package/resources/egress-guard/policy.h +60 -0
  24. package/resources/egress-guard/tests/guard_insert_test.c +291 -0
  25. package/resources/egress-guard/tests/guard_test.c +177 -0
  26. package/resources/egress-guard/tests/nettest.swift +172 -0
  27. package/resources/egress-guard/tests/policy_test.c +143 -0
  28. package/resources/egress-guard/tests/run-guard-tests.sh +62 -0
  29. package/resources/egress-guard/tests/run-policy-tests.sh +7 -0
@@ -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
@@ -0,0 +1,13 @@
1
+ import { type bunyan } from '@expo/logger';
2
+ export interface SandboxDaemonOptions {
3
+ credential: string;
4
+ serverUrl: string;
5
+ reconnectDelayMs: number;
6
+ logger: bunyan;
7
+ signal?: AbortSignal;
8
+ }
9
+ export interface SandboxDaemon {
10
+ ready: Promise<void>;
11
+ stopAsync(): Promise<void>;
12
+ }
13
+ export declare function startSandboxDaemonAsync(options: SandboxDaemonOptions): Promise<SandboxDaemon>;
@@ -0,0 +1,90 @@
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.startSandboxDaemonAsync = startSandboxDaemonAsync;
7
+ const eas_build_job_1 = require("@expo/eas-build-job");
8
+ const promises_1 = require("node:timers/promises");
9
+ const ws_1 = __importDefault(require("ws"));
10
+ async function startSandboxDaemonAsync(options) {
11
+ options.signal?.throwIfAborted();
12
+ let socket;
13
+ const abortController = new AbortController();
14
+ let hasConnected = false;
15
+ let resolveConnected;
16
+ let rejectConnected;
17
+ const connected = new Promise((resolve, reject) => {
18
+ resolveConnected = resolve;
19
+ rejectConnected = reject;
20
+ });
21
+ const stop = () => {
22
+ abortController.abort();
23
+ socket?.close(1000, 'sandbox stopped');
24
+ };
25
+ options.signal?.addEventListener('abort', stop, { once: true });
26
+ const connectionLoop = (async () => {
27
+ while (!abortController.signal.aborted) {
28
+ try {
29
+ socket = new ws_1.default(new URL('/sandbox/connect', options.serverUrl), {
30
+ handshakeTimeout: 10_000,
31
+ headers: { Authorization: `Bearer ${options.credential}` },
32
+ });
33
+ await waitForOpen(socket);
34
+ options.logger.info('Sandbox MCP server connected.');
35
+ hasConnected = true;
36
+ resolveConnected();
37
+ await waitForClose(socket);
38
+ }
39
+ catch (error) {
40
+ if (!hasConnected) {
41
+ const message = `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`;
42
+ if (!abortController.signal.aborted) {
43
+ options.logger.error({ err: error }, message);
44
+ }
45
+ rejectConnected(new eas_build_job_1.SystemError(message, { cause: error }));
46
+ return;
47
+ }
48
+ if (!abortController.signal.aborted) {
49
+ options.logger.warn({ err: error }, `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`);
50
+ }
51
+ }
52
+ if (!abortController.signal.aborted) {
53
+ try {
54
+ await (0, promises_1.setTimeout)(options.reconnectDelayMs, undefined, {
55
+ signal: abortController.signal,
56
+ });
57
+ }
58
+ catch (error) {
59
+ if (!abortController.signal.aborted) {
60
+ throw error;
61
+ }
62
+ }
63
+ }
64
+ }
65
+ })();
66
+ return {
67
+ ready: connected,
68
+ async stopAsync() {
69
+ options.signal?.removeEventListener('abort', stop);
70
+ stop();
71
+ await connectionLoop;
72
+ },
73
+ };
74
+ }
75
+ function waitForOpen(socket) {
76
+ return new Promise((resolve, reject) => {
77
+ socket.once('open', resolve);
78
+ socket.once('error', reject);
79
+ socket.once('close', () => reject(new Error('Sandbox MCP server closed before it connected.')));
80
+ });
81
+ }
82
+ function waitForClose(socket) {
83
+ if (socket.readyState === ws_1.default.CLOSING || socket.readyState === ws_1.default.CLOSED) {
84
+ return Promise.resolve();
85
+ }
86
+ return new Promise((resolve, reject) => {
87
+ socket.once('close', resolve);
88
+ socket.once('error', reject);
89
+ });
90
+ }