Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ This is the log of notable changes to EAS CLI and related packages.

- [build-tools] Normalize extracted project permissions so source archives cannot leave files unreadable by EAS workers. ([#4287](https://github.com/expo/eas-cli/pull/4287) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [eas-cli] Add dynamic retries to deploy command that scale up more generously, depending on deployment size ([#4299](https://github.com/expo/eas-cli/pull/4299) by [@kitten](https://github.com/kitten))
- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))

### 🧹 Chores
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
stopAsync.mockResolvedValue(undefined);
});

it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
stopAsync,
});

await runAsync(BuildRuntimePlatform.DARWIN);

expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
previewUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
},
})
);
});

it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export function createStartArgentRemoteSessionBuildFunction(
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl: webPreview.previewUrl },
remoteConfig: {
previewUrl: webPreview.previewUrl,
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
},
logger,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import { sleepAsync } from '../../../utils/retry';
import {
createExpoDeviceHubArgs,
Expand All @@ -32,6 +33,11 @@ jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('@expo/turtle-spawn');
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
jest.mock('../serveSimMetricsRecorder', () => {
const actual = jest.requireActual('../serveSimMetricsRecorder');
return { ...actual, readServeSimServersAsync: jest.fn(actual.readServeSimServersAsync) };
});

function createLoggerMock(): bunyan {
return {
Expand Down Expand Up @@ -129,6 +135,7 @@ describe(createServeSimArgs, () => {
'4321',
'--host',
'127.0.0.1',
'--require-token',
'--transport',
'webrtc',
'--webrtc-codec',
Expand Down Expand Up @@ -328,6 +335,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
jest.mocked(spawn).mockReset();
jest.mocked(ngrok.forward).mockReset();
jest.mocked(turtleFetch).mockReset();
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1', token: 'tok-1' }]);

const spawnPromise = Object.assign(Promise.resolve(undefined), {
child: {
Expand Down Expand Up @@ -388,6 +398,63 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
expect(close).toHaveBeenCalledTimes(1);
});

it('carries the serve-sim session token for Darwin', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);

const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});

expect(preview.previewToken).toBe('tok-1');
expect(preview.previewUrl).toBe('https://preview.example.test');
});

// serve-sim is always launched with --require-token, so a missing token means it is running
// ungated on a public tunnel. Failing beats handing out a preview that is dead or unprotected.
it('fails for Darwin when serve-sim reports no token', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);

await expect(
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
})
).rejects.toThrow(/started without a session token/);
});

// expo-device-hub mints no token, so the Android preview must not require one.
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);

const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});

expect(preview.previewToken).toBeUndefined();
const [, args] = jest.mocked(spawn).mock.calls[0];
expect(args).not.toContain('--require-token');
});

it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
const packageVersion = '4.5.6';
const close = jest.fn().mockResolvedValue(undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ describe(readServeSimServersAsync, () => {
]);
});

it('includes the session token when the state file carries one', async () => {
await writeServerStateAsync('E', { device: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' });

expect(await readServeSimServersAsync(stateDir)).toEqual([
{ udid: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' },
]);
});

it('returns [] when the state dir does not exist', async () => {
expect(await readServeSimServersAsync(path.join(workDir, 'nope'))).toEqual([]);
});
Expand All @@ -120,6 +128,20 @@ describe(streamServeSimMetricsToFileAsync, () => {
expect(String(jest.mocked(fetch).mock.calls[0][0])).toBe('https://sim.example/metrics');
});

it('sends the session token as a bearer header when given one', async () => {
jest.mocked(fetch).mockResolvedValue(sseResponse());
await streamServeSimMetricsToFileAsync({
serveSimUrl: 'https://sim.example',
serveSimToken: 'tok-abc',
filePath: path.join(workDir, 'auth.ndjson'),
signal: new AbortController().signal,
logger: createLoggerMock(),
});
expect(jest.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({
Authorization: 'Bearer tok-abc',
});
});

it('writes nothing on a non-ok response', async () => {
jest.mocked(fetch).mockResolvedValue(new Response('nope', { status: 502 }));
const filePath = path.join(workDir, 'empty.ndjson');
Expand Down
41 changes: 37 additions & 4 deletions packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';

const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const WEB_PREVIEW_HOST = '127.0.0.1';
Expand Down Expand Up @@ -627,6 +628,7 @@ export function createServeSimArgs({
String(port),
'--host',
WEB_PREVIEW_HOST,
'--require-token',
'--transport',
'webrtc',
'--webrtc-codec',
Expand Down Expand Up @@ -712,7 +714,7 @@ export async function waitForWebPreviewReadyAsync({
serverName: string;
port: number;
timeoutMs: number;
}): Promise<void> {
}): Promise<string> {
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
Expand All @@ -729,8 +731,8 @@ export async function waitForWebPreviewReadyAsync({
retries: 0,
timeout: 2_000,
});
WebPreviewReadyResponseSchema.parse(await response.json());
return;
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
return ready.device;
} catch (error) {
lastError = error;
}
Expand All @@ -745,6 +747,8 @@ export async function waitForWebPreviewReadyAsync({

export type DeviceWebPreviewHandle = {
previewUrl: string;
/** Session token gating the preview. Only serve-sim mints one. */
previewToken?: string;
stopAsync: () => Promise<void>;
};

Expand All @@ -760,6 +764,7 @@ async function startWebPreviewWithTunnelAsync(
serverName,
packageSpec,
createArgs,
readPreviewTokenAsync,
}: {
baseDomain: string;
env: BuildStepEnv;
Expand All @@ -768,6 +773,7 @@ async function startWebPreviewWithTunnelAsync(
serverName: string;
packageSpec: string;
createArgs: (port: number, turnArgs: string[]) => string[];
readPreviewTokenAsync?: (device: string) => Promise<string>;
}
): Promise<DeviceWebPreviewHandle> {
const port = await findAvailablePortAsync();
Expand All @@ -781,7 +787,13 @@ async function startWebPreviewWithTunnelAsync(

try {
logger.info(`Waiting for ${serverName} to become ready.`);
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
const device = await waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
});
const previewToken = await readPreviewTokenAsync?.(device);
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
Expand All @@ -791,6 +803,7 @@ async function startWebPreviewWithTunnelAsync(
});
return {
previewUrl: tunnel.url,
previewToken,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
for (const result of results) {
Expand All @@ -806,6 +819,14 @@ async function startWebPreviewWithTunnelAsync(
}
}

export async function readServeSimPreviewTokenAsync(
udid: string,
stateDir: string = SERVE_SIM_STATE_DIR
): Promise<string | undefined> {
const servers = await readServeSimServersAsync(stateDir);
return servers.find(server => server.udid === udid)?.token;
}

export async function startServeSimWithTunnelAsync(
ctx: CustomBuildContext,
{
Expand All @@ -832,6 +853,18 @@ export async function startServeSimWithTunnelAsync(
packageSpec: createServeSimPackageSpec(packageVersion),
createArgs: (port, turnArgs) =>
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
readPreviewTokenAsync: async device => {
const previewToken = await readServeSimPreviewTokenAsync(device);
if (!previewToken) {
throw new SystemError(
`serve-sim started without a session token for device ${device}. It is always launched ` +
'with --require-token, so this usually means the pinned @expo/serve-sim predates that ' +
'flag and ignored it, leaving the preview ungated on a public tunnel. Pin a serve-sim ' +
'version that supports --require-token.'
);
}
return previewToken;
},
});
}

Expand Down
19 changes: 15 additions & 4 deletions packages/build-tools/src/steps/utils/serveSimMetricsRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ const POLL_INTERVAL_MS = 2_000;
const MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE = 10;
const END_STREAM_TIMEOUT_MS = 2_000;
// serve-sim's own per-device registry while serving: `${tmpdir}/serve-sim/server-<udid>.json`.
const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
export const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');

type ServeSimServer = { udid: string; url: string };
type ServeSimServer = { udid: string; url: string; token?: string };

type ServeSimMetricsSession = {
logger: bunyan;
Expand Down Expand Up @@ -130,6 +130,7 @@ async function pollServeSimMetricsAsync(session: ServeSimMetricsSession): Promis
logger.info(`Collecting serve-sim metrics for ${server.udid}.`);
const donePromise = streamServeSimMetricsToFileAsync({
serveSimUrl: server.url,
serveSimToken: server.token,
filePath,
signal: streamSignal,
logger,
Expand Down Expand Up @@ -172,9 +173,14 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
const state = JSON.parse(await readFile(path.join(stateDir, entry), 'utf-8')) as {
device?: unknown;
url?: unknown;
token?: unknown;
};
if (typeof state.device === 'string' && typeof state.url === 'string') {
servers.push({ udid: state.device, url: state.url });
servers.push({
udid: state.device,
url: state.url,
...(typeof state.token === 'string' ? { token: state.token } : {}),
});
}
} catch {
continue;
Expand All @@ -185,11 +191,13 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS

export async function streamServeSimMetricsToFileAsync({
serveSimUrl,
serveSimToken,
filePath,
signal,
logger,
}: {
serveSimUrl: string;
serveSimToken?: string;
filePath: string;
signal: AbortSignal;
logger: bunyan;
Expand All @@ -201,7 +209,10 @@ export async function streamServeSimMetricsToFileAsync({
let receivedData = false;
let metadata: Record<string, unknown> | undefined;
try {
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { signal });
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), {
signal,
...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}),
});
if (!response.ok || !response.body) {
logger.warn(`serve-sim /metrics responded ${response.status} for ${serveSimUrl}.`);
return { receivedData, metadata };
Expand Down
Loading