From de159f5bc9ccdf75a7929c4bd6c60d2fb307aff0 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 01:25:25 -0700 Subject: [PATCH 1/2] [build-tools] Forward network capture through local egress Co-Authored-By: GPT-6.1-Sol --- .changeset/serve-sim-pr-4598.md | 5 ++ .../utils/__tests__/deviceSessionHost.test.ts | 70 +++++++++++++++++++ .../__tests__/remoteDeviceRunSession.test.ts | 8 +++ .../src/steps/utils/deviceSessionHost.ts | 12 ++++ 4 files changed, 95 insertions(+) create mode 100644 .changeset/serve-sim-pr-4598.md diff --git a/.changeset/serve-sim-pr-4598.md b/.changeset/serve-sim-pr-4598.md new file mode 100644 index 0000000000..327c8d680d --- /dev/null +++ b/.changeset/serve-sim-pr-4598.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': patch +--- + +Forward captured Simulator traffic through the local egress proxy. diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts index b1ec0c4403..1ed789c934 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts @@ -12,9 +12,11 @@ import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; import { verifyLocalEgressGuardAsync } from '../localEgressGuard'; import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession'; +import { readLocalEgressHandoffAsync } from '../localEgress'; import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions'; import { startServeSimActivityPolling } from '../serveSimActivity'; import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; +import { createProcessOutput } from '../processOutput'; import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts'; @@ -46,6 +48,10 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({ jest.mock('../localEgressGuard', () => ({ verifyLocalEgressGuardAsync: jest.fn() })); jest.mock('../serveSimActions'); jest.mock('../serveSimActivity'); +jest.mock('../localEgress', () => ({ + ...jest.requireActual('../localEgress'), + readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null), +})); jest.mock('../deviceRunSessionArtifacts'); jest.mock('../../../sentry'); jest.mock('../serveSimMetricsRecorder', () => ({ @@ -131,6 +137,7 @@ beforeEach(() => { jest.clearAllMocks(); jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReset().mockResolvedValue(undefined); jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined); + jest.mocked(readLocalEgressHandoffAsync).mockReset().mockResolvedValue(null); stopSessionPreview.mockResolvedValue(undefined); jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview }); jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined); @@ -1377,6 +1384,69 @@ it('uses the existing step logger for startup and shutdown output', async () => ).toBe(false); }); +it('passes the active local-egress address to serve-sim without tunnel credentials', async () => { + jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue({ + port: 8899, + url: 'https://tunnel.example', + token: 'tunnel-secret', + fingerprint: 'fingerprint', + }); + const host = await startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 10_000, + networkCapture: true, + }); + const spawned = jest.mocked(spawnDetached).mock.calls[0][0]; + expect(spawned.args).toEqual( + expect.arrayContaining(['--network-capture-proxy', 'http://127.0.0.1:8899']) + ); + expect(spawned.args).toContain('--network-capture'); + expect(JSON.stringify(spawned)).not.toContain('tunnel-secret'); + await host.finishAsync(); +}); + +it('passes an explicit authenticated capture proxy as a launch argument without logging it', async () => { + const proxy = 'http://user:proxy-secret@proxy.example:8899'; + const host = await startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 10_000, + networkCaptureProxy: proxy, + }); + const spawned = jest.mocked(spawnDetached).mock.calls[0][0]; + expect(spawned.args).toEqual(expect.arrayContaining(['--network-capture-proxy', proxy])); + const output = createProcessOutput(logger, spawned.secrets); + output.stderr.append(`Command failed: serve-sim --network-capture-proxy ${proxy}\n`); + output.finish(); + expect(output.getOutput()).not.toContain('proxy-secret'); + expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain('proxy-secret'); + expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled(); + await host.finishAsync(); +}); + +it('lets a direct capture launch parameter override local egress', async () => { + const host = await startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 10_000, + networkCaptureProxy: 'none', + }); + const spawned = jest.mocked(spawnDetached).mock.calls[0][0]; + expect(spawned.args).toEqual(expect.arrayContaining(['--network-capture-proxy', 'none'])); + expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled(); + await host.finishAsync(); +}); + +it('does not read the iOS egress handoff for an Android host', async () => { + const host = await startHostAsync(); + expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled(); + await host.finishAsync(); +}); + it.each([true, false])( 'waits for setup before launch or boot-only readiness (app: %s)', async hasApp => { diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 64975fcf6b..0e94c21d54 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -661,6 +661,14 @@ describe(createServeSimArgs, () => { ); }); + it('passes a capture proxy as one argument, including when capture will be enabled later', () => { + const proxy = 'http://user:p%40ss%20word@proxy.example:8899'; + const args = createServeSimArgs({ port: 4321, networkCaptureProxy: proxy }); + expect(args.slice(-2)).toEqual(['--network-capture-proxy', proxy]); + expect(args).not.toContain('--network-capture'); + expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture-proxy'); + }); + it('appends --network-capture when enabled, which also covers an already booted simulator', () => { const args = createServeSimArgs({ port: 4321, networkCapture: true }); expect(args).toContain('--network-capture'); diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 148f5c8660..878851e835 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -40,6 +40,7 @@ import { spawnDetached, startNgrokTunnelAsync, } from './remoteDeviceRunSession'; +import { LOCAL_EGRESS_PROXY_HOST, readLocalEgressHandoffAsync } from './localEgress'; import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout'; import { IosSimulatorRecordingUtils, @@ -137,6 +138,7 @@ export function createServeSimArgs({ openUrl, networkCapture = false, networkCaptureFields = [], + networkCaptureProxy, }: { port: number; turnArgs?: string[]; @@ -150,6 +152,7 @@ export function createServeSimArgs({ installAppPath?: string; networkCapture?: boolean; networkCaptureFields?: string[]; + networkCaptureProxy?: string; } & ServeSimLaunchOptions): string[] { return [ createServeSimPackageSpec(packageVersion), @@ -189,6 +192,7 @@ export function createServeSimArgs({ ...networkCaptureFields.flatMap(field => ['--network-capture-field', field]), ] : []), + ...(networkCaptureProxy !== undefined ? ['--network-capture-proxy', networkCaptureProxy] : []), ]; } @@ -389,6 +393,7 @@ type DeviceSessionHostOptions = { onStartupError?: (error: unknown) => void; networkCapture?: boolean; networkCaptureFields?: string[]; + networkCaptureProxy?: string; } & ServeSimLaunchOptions; export async function startDeviceSessionHostAsync( @@ -439,6 +444,7 @@ async function startDeviceSessionHostInternalAsync( openUrl, networkCapture = false, networkCaptureFields = [], + networkCaptureProxy, bootEnv = {}, }: DeviceSessionHostOptions, onFinished: (successful: boolean) => void, @@ -496,6 +502,8 @@ async function startDeviceSessionHostInternalAsync( ? createExpoDeviceHubPackageSpec(packageVersion) : createServeSimPackageSpec(packageVersion); const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger }); + const localEgress = + !isAndroid && networkCaptureProxy === undefined ? await readLocalEgressHandoffAsync() : null; const previewExec = resolvePackageExec( resolveConfiguredPackageManager(env, PackageManager.NPM), isAndroid @@ -525,6 +533,9 @@ async function startDeviceSessionHostInternalAsync( openUrl: iosSimulatorUdid ? undefined : openUrl, networkCapture, networkCaptureFields, + networkCaptureProxy: + networkCaptureProxy ?? + (localEgress ? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}` : undefined), }) ); logger.info( @@ -532,6 +543,7 @@ async function startDeviceSessionHostInternalAsync( ); // Redact these credentials before forwarding process output to build logs. const secrets = [ + ...(networkCaptureProxy?.includes('@') ? [networkCaptureProxy] : []), ...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'), ...(recording ? [recording.controlToken] : []), ]; From 21feac83beb1a86722eead3f8d5ced625e296e90 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 15 Oct 2026 12:25:23 -0700 Subject: [PATCH 2/2] [build-tools] Remove unused capture proxy override Co-Authored-By: Codex --- .../utils/__tests__/deviceSessionHost.test.ts | 35 ------------------- .../src/steps/utils/deviceSessionHost.ts | 12 +++---- 2 files changed, 4 insertions(+), 43 deletions(-) diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts index 1ed789c934..3d1722c9ba 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts @@ -16,7 +16,6 @@ import { readLocalEgressHandoffAsync } from '../localEgress'; import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions'; import { startServeSimActivityPolling } from '../serveSimActivity'; import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; -import { createProcessOutput } from '../processOutput'; import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts'; @@ -1407,40 +1406,6 @@ it('passes the active local-egress address to serve-sim without tunnel credentia await host.finishAsync(); }); -it('passes an explicit authenticated capture proxy as a launch argument without logging it', async () => { - const proxy = 'http://user:proxy-secret@proxy.example:8899'; - const host = await startDeviceSessionHostAsync(ctx, { - runtimePlatform: BuildRuntimePlatform.DARWIN, - env, - logger, - timeoutMs: 10_000, - networkCaptureProxy: proxy, - }); - const spawned = jest.mocked(spawnDetached).mock.calls[0][0]; - expect(spawned.args).toEqual(expect.arrayContaining(['--network-capture-proxy', proxy])); - const output = createProcessOutput(logger, spawned.secrets); - output.stderr.append(`Command failed: serve-sim --network-capture-proxy ${proxy}\n`); - output.finish(); - expect(output.getOutput()).not.toContain('proxy-secret'); - expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain('proxy-secret'); - expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled(); - await host.finishAsync(); -}); - -it('lets a direct capture launch parameter override local egress', async () => { - const host = await startDeviceSessionHostAsync(ctx, { - runtimePlatform: BuildRuntimePlatform.DARWIN, - env, - logger, - timeoutMs: 10_000, - networkCaptureProxy: 'none', - }); - const spawned = jest.mocked(spawnDetached).mock.calls[0][0]; - expect(spawned.args).toEqual(expect.arrayContaining(['--network-capture-proxy', 'none'])); - expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled(); - await host.finishAsync(); -}); - it('does not read the iOS egress handoff for an Android host', async () => { const host = await startHostAsync(); expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled(); diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 878851e835..adc087fe8b 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -393,7 +393,6 @@ type DeviceSessionHostOptions = { onStartupError?: (error: unknown) => void; networkCapture?: boolean; networkCaptureFields?: string[]; - networkCaptureProxy?: string; } & ServeSimLaunchOptions; export async function startDeviceSessionHostAsync( @@ -444,7 +443,6 @@ async function startDeviceSessionHostInternalAsync( openUrl, networkCapture = false, networkCaptureFields = [], - networkCaptureProxy, bootEnv = {}, }: DeviceSessionHostOptions, onFinished: (successful: boolean) => void, @@ -502,8 +500,7 @@ async function startDeviceSessionHostInternalAsync( ? createExpoDeviceHubPackageSpec(packageVersion) : createServeSimPackageSpec(packageVersion); const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger }); - const localEgress = - !isAndroid && networkCaptureProxy === undefined ? await readLocalEgressHandoffAsync() : null; + const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null; const previewExec = resolvePackageExec( resolveConfiguredPackageManager(env, PackageManager.NPM), isAndroid @@ -533,9 +530,9 @@ async function startDeviceSessionHostInternalAsync( openUrl: iosSimulatorUdid ? undefined : openUrl, networkCapture, networkCaptureFields, - networkCaptureProxy: - networkCaptureProxy ?? - (localEgress ? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}` : undefined), + networkCaptureProxy: localEgress + ? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}` + : undefined, }) ); logger.info( @@ -543,7 +540,6 @@ async function startDeviceSessionHostInternalAsync( ); // Redact these credentials before forwarding process output to build logs. const secrets = [ - ...(networkCaptureProxy?.includes('@') ? [networkCaptureProxy] : []), ...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'), ...(recording ? [recording.controlToken] : []), ];