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))
- [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
12 changes: 12 additions & 0 deletions packages/eas-cli/graphql.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions packages/eas-cli/src/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,27 @@ export const DeviceRunSessionQuery = {
agentDeviceRemoteSessionUrl
agentDeviceRemoteSessionToken
webPreviewUrl
webPreviewToken
}
... on ArgentRunSessionRemoteConfig {
toolsUrl
toolsAuthToken
webPreviewUrl
webPreviewToken
}
... on AppiumRunSessionRemoteConfig {
appiumUrl
capabilities
webPreviewUrl
webPreviewToken
}
... on ServeSimRunSessionRemoteConfig {
previewUrl
previewToken

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Format and sanitize gated preview data in JSON output too

Both eas simulator --json and eas simulator get --json serialize remoteConfig directly, bypassing formatPreviewUrl. A gated session therefore returns a bare previewUrl/webPreviewUrl that responds with 401, alongside the bearer token as a separate field.

Please produce a sanitized JSON remote config whose preview URL has passed through formatPreviewUrl and omit the standalone preview token. Alternatively, explicitly document and test JSON output as a separate low-level { url, token } contract.

}
... on WebPreviewOnlyRunSessionRemoteConfig {
previewUrl: webPreviewUrl
previewToken: webPreviewToken
}
}
turtleJobRun {
Expand Down
158 changes: 158 additions & 0 deletions packages/eas-cli/src/simulator/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import {
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
EAS_SIMULATOR_WAITLIST_URL,
deviceRunSessionTypeToFlagValue,
formatPreviewUrl,
formatRemoteSessionInstructions,
formatSimulatorUnavailableMessage,
getRemoteSessionEnvironmentVariables,
} from '../utils';

Expand Down Expand Up @@ -73,3 +76,158 @@ describe('simulator resource class flags', () => {
);
});
});

describe(formatPreviewUrl, () => {
it('appends the session token for a gated preview', () => {
expect(formatPreviewUrl('https://preview.example.test', 'tok-1')).toBe(
'https://preview.example.test/?token=tok-1'
);
});

it('leaves the url alone when the preview is ungated', () => {
expect(formatPreviewUrl('https://preview.example.test', null)).toBe(
'https://preview.example.test'
);
expect(formatPreviewUrl('https://preview.example.test', undefined)).toBe(
'https://preview.example.test'
);
});
});

describe('gated preview links', () => {
const PREVIEW = 'https://preview.example.test';
const GATED = `${PREVIEW}/?token=tok-1`;

it('prints the tokenized preview for a serve-sim session', () => {
const instructions = formatRemoteSessionInstructions(
{
__typename: 'ServeSimRunSessionRemoteConfig' as const,
previewUrl: PREVIEW,
previewToken: 'tok-1',
},
'env'
);

expect(instructions).toContain(GATED);
});

it('prints the tokenized preview for a web-preview-only session', () => {
const instructions = formatRemoteSessionInstructions(
{
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: PREVIEW,
previewToken: 'tok-1',
},
'env'
);

expect(instructions).toContain(GATED);
});

it('prints the tokenized preview for every controller session type', () => {
const controllers = [
{
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
agentDeviceRemoteSessionUrl: 'https://daemon.example.test',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: PREVIEW,
webPreviewToken: 'tok-1',
},
{
__typename: 'ArgentRunSessionRemoteConfig' as const,
toolsUrl: 'https://argent.example.test',
toolsAuthToken: 'argent-token',
webPreviewUrl: PREVIEW,
webPreviewToken: 'tok-1',
},
{ ...iosAppiumConfig, webPreviewToken: 'tok-1' },
];

for (const remoteConfig of controllers) {
expect(formatRemoteSessionInstructions(remoteConfig, 'env')).toContain(GATED);
}
});

it('prints the plain url when the preview is ungated', () => {
const instructions = formatRemoteSessionInstructions(
{ __typename: 'ServeSimRunSessionRemoteConfig' as const, previewUrl: PREVIEW },
'env'
);

expect(instructions).toContain(PREVIEW);
expect(instructions).not.toContain('token=');
});
});

describe('preview-only session environment', () => {
it.each([
{ __typename: 'ServeSimRunSessionRemoteConfig' as const, previewUrl: 'https://p.example.test' },
{
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: 'https://p.example.test',
},
])('has no controller variables for $__typename', remoteConfig => {
expect(getRemoteSessionEnvironmentVariables(remoteConfig)).toEqual({});
});
});

describe('Argent session without a tools token', () => {
const untokenizedArgent = {
__typename: 'ArgentRunSessionRemoteConfig' as const,
toolsUrl: 'https://argent.example.test',
webPreviewUrl: null,
};

it('omits the auth variable', () => {
expect(getRemoteSessionEnvironmentVariables(untokenizedArgent)).toEqual({
ARGENT_TOOLS_URL: 'https://argent.example.test',
});
});

it('omits the --token flag from the link command', () => {
const instructions = formatRemoteSessionInstructions(untokenizedArgent, 'dotenv');

expect(instructions).toContain("argent link 'https://argent.example.test'");
expect(instructions).not.toContain('--token');
});
});

describe('dotenv instructions', () => {
it('tells an agent-device session to use simulator:exec', () => {
const instructions = formatRemoteSessionInstructions(
{
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
agentDeviceRemoteSessionUrl: 'https://daemon.example.test',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: null,
},
'dotenv'
);

expect(instructions).toContain('eas simulator:exec npx agent-device <command>');
});

it('tells an argent session to link its local client', () => {
const instructions = formatRemoteSessionInstructions(
{
__typename: 'ArgentRunSessionRemoteConfig' as const,
toolsUrl: 'https://argent.example.test',
toolsAuthToken: 'argent-token',
webPreviewUrl: null,
},
'dotenv'
);

expect(instructions).toContain('link your local Argent client');
expect(instructions).toContain("--token 'argent-token'");
});
});

describe(formatSimulatorUnavailableMessage, () => {
it('names the account and points at the waitlist', () => {
const message = formatSimulatorUnavailableMessage('acme');

expect(message).toContain('acme');
expect(message).toContain(EAS_SIMULATOR_WAITLIST_URL);
});
});
28 changes: 23 additions & 5 deletions packages/eas-cli/src/simulator/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ export function getRemoteSessionEnvironmentVariables(

type RemoteSessionInstructionsConfigType = 'env' | 'dotenv';

/**
* Preview link for a session. A gated serve-sim needs the session token, and a browser cannot send
* a header on a page load, so it rides the query. serve-sim swaps it for a cookie on the first load.
*/
export function formatPreviewUrl(url: string, token: string | null | undefined): string {
if (!token) {
return url;
}
const withToken = new URL(url);
withToken.searchParams.set('token', token);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up idea: What if the URL would carry a distinct, least-privilege credential - ideally a short-lived, one-time bootstrap token exchanged for a secure cookie. Browser controls should authorize only typed simulator operations, never arbitrary commands. We are also planning to move streaming to expo.dev so we should also plan having it in mind.

return withToken.toString();
}

export function formatRemoteSessionInstructions(
remoteConfig: DeviceRunSessionRemoteConfig,
configType: RemoteSessionInstructionsConfigType
Expand All @@ -112,7 +125,7 @@ export function formatRemoteSessionInstructions(
'',
'🌐 Open the following URL in your browser to preview the simulator:',
'',
remoteConfig.webPreviewUrl
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
);
}
return lines.join('\n');
Expand Down Expand Up @@ -150,7 +163,7 @@ export function formatRemoteSessionInstructions(
'',
'🌐 Open the following URL in your browser to preview the simulator:',
'',
remoteConfig.webPreviewUrl
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
);
}
return lines.join('\n');
Expand All @@ -174,21 +187,26 @@ export function formatRemoteSessionInstructions(
'<appium-client> [args...]',
];
if (remoteConfig.webPreviewUrl) {
lines.push('', 'Open the simulator preview:', '', remoteConfig.webPreviewUrl);
lines.push(
'',
'Open the simulator preview:',
'',
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
);
}
return lines.join('\n');
}
case 'ServeSimRunSessionRemoteConfig':
return [
'🌐 Open the following URL in your browser to access the simulator:',
'',
remoteConfig.previewUrl,
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
].join('\n');
case 'WebPreviewOnlyRunSessionRemoteConfig':
return [
'🌐 Open the following URL in your browser to access the simulator:',
'',
remoteConfig.previewUrl,
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
].join('\n');
}
}
Loading