Compare commits

..
5 Commits
Author SHA1 Message Date
Expo CI 66cdccb0e7 v24.3.0 2026-09-11 12:31:21 +00:00
Stanisław Chmiela 2eefa44f8f [eas-build-job] Add sandbox daemon protocol (#4346)
## Why

"sandboxd" from `build-tools` needs to connect to the MCP server in `universe`. They need to be able to talk to each other. I'm thinking it might be nice if they shared an "API protocol"?

## How

Added Zod schema for input and output of to-be-supported messages to `eas-build-job`.

## Test plan

CI should pass. I think I tested this locally too.
2026-09-11 14:23:53 +02:00
Stanisław Chmiela 55302dd830 [build-tools] Add sandbox MCP connection step (#4333)
# Why

See https://github.com/expo/universe/pull/30766.

# How

Added a simple "sandbox daemon" that connects to MCP and an `eas/start_sandbox` EAS function.

This sets up the basic structure, planning to take care of protocol, handling messages and executing commands will come in future PRs.

# Test Plan

Tested with https://github.com/expo/universe/pull/30766 and https://github.com/expo/universe/pull/30763.

```
<-- GET /sandbox/connect
New sandbox daemon - sandboxId[01a0692f-b464-77c8-8ab0-e2143eec9872] ipAddress[::1]
--> GET /sandbox/connect 101 23ms
[wrangler:info] GET /sandbox/connect 101 Switching Protocols (50ms)
```
2026-09-11 14:02:24 +02:00
Expo CI 924eb87157 bump STAGING and PRODUCTION to 24.2.0 in cli-versions.json 2026-09-11 11:01:44 +00:00
Expo CI f546bf3352 update CHANGELOG.md for v24.2.0 2026-09-11 10:55:55 +00:00
21 changed files with 885 additions and 174 deletions

No files matched your search

+10
View File
@@ -8,6 +8,16 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-build-job] Add a shared protocol for sandbox daemon commands. ([#4346](https://github.com/expo/eas-cli/pull/4346) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
### 🧹 Chores
## [24.2.0](https://github.com/expo/eas-cli/releases/tag/v24.2.0) - 2026-09-11
### 🎉 New features
- [eas-build-job] Remove repository URLs from Git job inputs and fetch them when resolving project sources. ([#4360](https://github.com/expo/eas-cli/pull/4360) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Add `eas workflow:insights` to display Workflows Insights for a time range: run counts and success rate compared with the previous period, runs over time, and a per-workflow breakdown, with `--json` output for scripts. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Add `eas workflow:insights:maestro` to display Maestro test insights for a time range: pass and flake rates, a sortable per-flow table, and a single flow's history with error patterns and recent runs, with `--json` output for scripts. ([#4370](https://github.com/expo/eas-cli/pull/4370) by [@hSATAC](https://github.com/hSATAC))
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.1.2",
"PRODUCTION": "24.1.2"
"STAGING": "24.2.0",
"PRODUCTION": "24.2.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.2.0",
"version": "24.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.2.0",
"version": "24.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -40,14 +40,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.2.0",
"@expo/eas-build-job": "24.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.2.0",
"@expo/steps": "24.3.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -75,6 +75,7 @@
"semver": "^7.6.2",
"tar": "7.5.19",
"uuid": "9.0.1",
"ws": "8.21.1",
"yaml": "^2.8.1",
"zod": "^4.3.5"
},
@@ -92,6 +93,7 @@
"@types/retry": "^0.12.5",
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.8",
"@types/ws": "8.5.10",
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
@@ -54,6 +54,7 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
@@ -110,6 +111,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartServeSimMetricsBuildFunction(),
createStartSandboxBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
@@ -0,0 +1,131 @@
import { SystemError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
describe('sandbox build functions', () => {
it('aborts an active ready request and keeps its authentication headers', async () => {
const controller = new AbortController();
let requestStarted!: () => void;
const started = new Promise<void>(resolve => {
requestStarted = resolve;
});
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
return await new Promise<Response>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
requestStarted();
});
});
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
});
try {
const pending = markSandboxReadyAsync(
{ graphqlClient } as any,
'sandbox-id',
controller.signal
);
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
await started;
controller.abort();
await rejected;
} finally {
fetchMock.mockRestore();
}
});
it('does not mark the sandbox ready if startup was canceled', async () => {
const controller = new AbortController();
const stopAsync = jest.fn(async () => {});
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockImplementation(async options => {
expect(options.signal).toBe(controller.signal);
controller.abort();
return { ready: Promise.resolve(), stopAsync };
});
const mutation = jest.fn();
const fn = createStartSandboxBuildFunction({
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
signal: controller.signal,
})
).rejects.toThrow();
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
} finally {
start.mockRestore();
}
});
it('provides one long-running step', () => {
const ctx = { job: {} } as any;
expect(createStartSandboxBuildFunction(ctx).getFullId()).toBe('eas/start_sandbox');
});
it('throws a system error when the sandbox token is missing', async () => {
const fn = createStartSandboxBuildFunction({
job: {},
env: {},
mcpServerUrl: 'ws://localhost:8787',
} as any);
await expect(
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
})
).rejects.toBeInstanceOf(SystemError);
});
it('throws a system error when the MCP server URL is missing', async () => {
const fn = createStartSandboxBuildFunction({
job: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
} as any);
await expect(
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
})
).rejects.toBeInstanceOf(SystemError);
});
it('marks the sandbox as ready', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
}));
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id' },
undefined
);
});
it('throws a system error when marking the sandbox as ready fails', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({ error: new Error('request failed') }),
}));
await expect(
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
});
});
@@ -0,0 +1,105 @@
import { SystemError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../customBuildContext';
import { withLogPhaseAsync } from '../../utils/logPhase';
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
const MARK_SANDBOX_READY_MUTATION = graphql(`
mutation MarkSandboxReady($sandboxId: ID!) {
sandbox {
markSandboxReady(sandboxId: $sandboxId) {
id
}
}
}
`);
const RECONNECT_DELAY_MS = 1_000;
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_sandbox',
name: 'Start sandbox daemon',
__metricsId: 'eas/start_sandbox',
inputProviders: [
BuildStepInput.createProvider({
id: 'sandbox_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, signal }) => {
const sandboxToken = ctx.env.__EAS_SANDBOX_MCP_TOKEN;
if (!sandboxToken) {
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
}
const mcpServerUrl = ctx.mcpServerUrl;
if (!mcpServerUrl) {
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
}
const sandboxId = String(inputs.sandbox_id.value);
const daemon = await startSandboxDaemonAsync({
credential: sandboxToken,
serverUrl: mcpServerUrl,
reconnectDelayMs: RECONNECT_DELAY_MS,
logger: stepCtx.logger,
signal,
});
try {
await daemon.ready;
signal?.throwIfAborted();
await markSandboxReadyAsync(ctx, sandboxId, signal);
signal?.throwIfAborted();
stepCtx.logger.info('Sandbox daemon started.');
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
await waitForCancellationAsync(signal);
});
} finally {
await daemon.stopAsync();
}
},
});
}
export async function markSandboxReadyAsync(
ctx: CustomBuildContext,
sandboxId: string,
signal?: AbortSignal
): Promise<void> {
signal?.throwIfAborted();
const result = await ctx.graphqlClient
.mutation(
MARK_SANDBOX_READY_MUTATION,
{ sandboxId },
signal
? {
fetch: (input, init) =>
fetch(input, {
...init,
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
}),
}
: undefined
)
.toPromise();
signal?.throwIfAborted();
if (result.error) {
throw new SystemError(`Failed to mark sandbox ${sandboxId} as ready.`, {
cause: result.error,
});
}
}
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return;
}
await new Promise<void>(resolve => {
signal?.addEventListener('abort', () => resolve(), { once: true });
});
}
@@ -0,0 +1,203 @@
import { SystemError } from '@expo/eas-build-job';
import Log from '@expo/logger';
import http from 'node:http';
import net, { AddressInfo } from 'node:net';
import WebSocket, { WebSocketServer } from 'ws';
import { startSandboxDaemonAsync } from '../sandboxDaemon';
jest.unmock('@expo/logger');
describe(startSandboxDaemonAsync.name, () => {
const logger = Log.child({ buildStepId: 'sandbox-test' });
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl,
reconnectDelayMs: 10,
logger,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
expect(errorLog).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: expect.any(String) }) },
expect.any(String)
);
} finally {
await daemon.stopAsync();
errorLog.mockRestore();
}
});
it('logs errors after the connection opens', async () => {
const server = new WebSocketServer({ port: 0, host: '127.0.0.1' });
await new Promise<void>(resolve => server.once('listening', resolve));
const connection = new Promise<WebSocket>(resolve => server.once('connection', resolve));
const warn = jest.spyOn(logger, 'warn').mockImplementation(() => {});
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${(server.address() as AddressInfo).port}`,
reconnectDelayMs: 60_000,
logger,
});
try {
const socket = await connection;
await daemon.ready;
// An invalid opcode makes the client emit a protocol error.
const closed = new Promise<void>(resolve => socket.once('close', () => resolve()));
(socket as any)._socket.write(Buffer.from([0x83, 0x00]));
await closed;
expect(warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: expect.stringContaining('invalid opcode') }) },
expect.stringContaining('invalid opcode')
);
} finally {
await daemon.stopAsync();
await new Promise<void>(resolve => server.close(() => resolve()));
warn.mockRestore();
}
});
it('fails if the server does not complete the handshake', async () => {
const sockets = new Set<net.Socket>();
const tcpServer = net.createServer(socket => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
await new Promise<void>(resolve => tcpServer.listen(0, '127.0.0.1', resolve));
const address = tcpServer.address() as AddressInfo;
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
expect(errorLog).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: 'Opening handshake has timed out' }) },
expect.stringContaining('Opening handshake has timed out')
);
} finally {
await daemon.stopAsync();
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>(resolve => tcpServer.close(() => resolve()));
errorLog.mockRestore();
}
}, 15_000);
it.each(['stop', 'abort'])('can %s while the initial connection is pending', async action => {
const controller = new AbortController();
const tcpServer = net.createServer();
await new Promise<void>(resolve => tcpServer.listen(0, '127.0.0.1', resolve));
const address = tcpServer.address() as AddressInfo;
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
signal: controller.signal,
});
const connectionError = daemon.ready.catch(error => error);
if (action === 'abort') {
controller.abort();
} else {
await daemon.stopAsync();
}
await expect(connectionError).resolves.toBeInstanceOf(Error);
await daemon.stopAsync();
await new Promise<void>(resolve => tcpServer.close(() => resolve()));
});
it('fails when the initial connection cannot be opened', async () => {
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
const httpServer = http.createServer();
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
await new Promise<void>(resolve => httpServer.close(() => resolve()));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
});
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
expect(errorLog).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: expect.stringContaining('ECONNREFUSED') }) },
expect.stringContaining('ECONNREFUSED')
);
await daemon.stopAsync();
errorLog.mockRestore();
});
it('connects with the credential and closes on stop', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
expect(request.url).toBe('/sandbox/connect');
expect(request.headers.authorization).toBe('Bearer secret-token');
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemonPromise = startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
});
const socket = await connection;
const daemon = await daemonPromise;
await daemon.ready;
const socketClosed = new Promise<void>(resolve => socket.once('close', () => resolve()));
await daemon.stopAsync();
await socketClosed;
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('cancels the reconnect delay when stopped', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemonPromise = startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
});
const socket = await connection;
const daemon = await daemonPromise;
await daemon.ready;
const socketClosed = new Promise<void>(resolve => socket.once('close', () => resolve()));
socket.close();
await socketClosed;
await new Promise<void>(resolve => setImmediate(resolve));
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
});
@@ -0,0 +1,106 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import WebSocket from 'ws';
export interface SandboxDaemonOptions {
credential: string;
serverUrl: string;
reconnectDelayMs: number;
logger: bunyan;
signal?: AbortSignal;
}
export interface SandboxDaemon {
ready: Promise<void>;
stopAsync(): Promise<void>;
}
export async function startSandboxDaemonAsync(
options: SandboxDaemonOptions
): Promise<SandboxDaemon> {
options.signal?.throwIfAborted();
let socket: WebSocket | undefined;
const abortController = new AbortController();
let hasConnected = false;
let resolveConnected!: () => void;
let rejectConnected!: (error: Error) => void;
const connected = new Promise<void>((resolve, reject) => {
resolveConnected = resolve;
rejectConnected = reject;
});
const stop = (): void => {
abortController.abort();
socket?.close(1000, 'sandbox stopped');
};
options.signal?.addEventListener('abort', stop, { once: true });
const connectionLoop = (async () => {
while (!abortController.signal.aborted) {
try {
socket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
handshakeTimeout: 10_000,
headers: { Authorization: `Bearer ${options.credential}` },
});
await waitForOpen(socket);
options.logger.info('Sandbox MCP server connected.');
hasConnected = true;
resolveConnected();
await waitForClose(socket);
} catch (error: any) {
if (!hasConnected) {
const message = `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`;
if (!abortController.signal.aborted) {
options.logger.error({ err: error }, message);
}
rejectConnected(new SystemError(message, { cause: error }));
return;
}
if (!abortController.signal.aborted) {
options.logger.warn(
{ err: error },
`Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`
);
}
}
if (!abortController.signal.aborted) {
try {
await setTimeoutAsync(options.reconnectDelayMs, undefined, {
signal: abortController.signal,
});
} catch (error) {
if (!abortController.signal.aborted) {
throw error;
}
}
}
}
})();
return {
ready: connected,
async stopAsync(): Promise<void> {
options.signal?.removeEventListener('abort', stop);
stop();
await connectionLoop;
},
};
}
function waitForOpen(socket: WebSocket): Promise<void> {
return new Promise((resolve, reject) => {
socket.once('open', resolve);
socket.once('error', reject);
socket.once('close', () => reject(new Error('Sandbox MCP server closed before it connected.')));
});
}
function waitForClose(socket: WebSocket): Promise<void> {
if (socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
socket.once('close', resolve);
socket.once('error', reject);
});
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "24.2.0",
"version": "24.3.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "24.2.0"
"@expo/steps": "24.3.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "24.2.0",
"version": "24.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -0,0 +1,66 @@
import { SandboxDaemonCommands, SandboxDaemonRequestZ, SandboxDaemonResponseZ } from '../sandbox';
describe('sandbox daemon protocol', () => {
it('validates command parameters', () => {
expect(
SandboxDaemonCommands.execCommand.params.parse({
cmd: 'pwd',
workdir: '/tmp',
tty: true,
yieldTimeMs: 10,
})
).toEqual({ cmd: 'pwd', workdir: '/tmp', tty: true, yieldTimeMs: 10 });
expect(
SandboxDaemonCommands.writeStdin.params.parse({
sessionId: 1,
chars: '\u0003',
yieldTimeMs: 0,
})
).toEqual({ sessionId: 1, chars: '\u0003', yieldTimeMs: 0 });
});
it.each([
{ output: '', wallTimeSeconds: 0, exitCode: 0 },
{ output: '', wallTimeSeconds: 0, terminationSignal: 'SIGTERM' },
{ output: '', wallTimeSeconds: 0, sessionId: 1 },
])('validates a command result with one terminal state', result => {
expect(SandboxDaemonCommands.execCommand.result.parse(result)).toEqual(result);
});
it.each([
{ output: '', wallTimeSeconds: 0 },
{ output: '', wallTimeSeconds: 0, exitCode: 0, sessionId: 1 },
])('rejects a command result without exactly one terminal state', result => {
expect(() => SandboxDaemonCommands.writeStdin.result.parse(result)).toThrow();
});
it('validates success and error response envelopes', () => {
expect(
SandboxDaemonRequestZ.parse({
jsonrpc: '2.0',
id: 'request-id',
method: 'execCommand',
params: { cmd: 'pwd' },
})
).toEqual({
jsonrpc: '2.0',
id: 'request-id',
method: 'execCommand',
params: { cmd: 'pwd' },
});
expect(
SandboxDaemonResponseZ.parse({ jsonrpc: '2.0', id: 'request-id', result: { output: '' } })
).toEqual({ jsonrpc: '2.0', id: 'request-id', result: { output: '' } });
expect(
SandboxDaemonResponseZ.parse({
jsonrpc: '2.0',
id: 'request-id',
error: { code: -32603, message: 'Command failed' },
})
).toEqual({
jsonrpc: '2.0',
id: 'request-id',
error: { code: -32603, message: 'Command failed' },
});
});
});
+1
View File
@@ -34,6 +34,7 @@ export * from './compositeFunction';
export * from './submission-config';
export * from './projectPackage';
export * from './deviceRunSession';
export * from './sandbox';
const version = require('../package.json').version;
export { version };
+83
View File
@@ -0,0 +1,83 @@
import { z } from 'zod';
export const SandboxDaemonCommands = {
execCommand: {
params: z.object({
cmd: z.string().min(1),
workdir: z.string().optional(),
tty: z.boolean().optional(),
yieldTimeMs: z.number().int().min(0).max(30_000).optional(),
}),
result: z.union([
z.strictObject({
output: z.string(),
wallTimeSeconds: z.number().nonnegative(),
exitCode: z.number().int(),
}),
z.strictObject({
output: z.string(),
wallTimeSeconds: z.number().nonnegative(),
terminationSignal: z.string().min(1),
}),
z.strictObject({
output: z.string(),
wallTimeSeconds: z.number().nonnegative(),
sessionId: z.number().int().positive(),
}),
]),
},
writeStdin: {
params: z.object({
sessionId: z.number().int().positive(),
chars: z.string().optional(),
yieldTimeMs: z.number().int().min(0).max(30_000).optional(),
}),
result: z.union([
z.strictObject({
output: z.string(),
wallTimeSeconds: z.number().nonnegative(),
exitCode: z.number().int(),
}),
z.strictObject({
output: z.string(),
wallTimeSeconds: z.number().nonnegative(),
terminationSignal: z.string().min(1),
}),
z.strictObject({
output: z.string(),
wallTimeSeconds: z.number().nonnegative(),
sessionId: z.number().int().positive(),
}),
]),
},
} as const satisfies Record<string, { params: z.ZodType; result: z.ZodType }>;
export type SandboxDaemonMethod = keyof typeof SandboxDaemonCommands;
export type SandboxDaemonCommandParams<Method extends SandboxDaemonMethod> = z.output<
(typeof SandboxDaemonCommands)[Method]['params']
>;
export type SandboxDaemonCommandResult<Method extends SandboxDaemonMethod> = z.output<
(typeof SandboxDaemonCommands)[Method]['result']
>;
export const SandboxDaemonRequestZ = z.object({
jsonrpc: z.literal('2.0'),
id: z.string(),
method: z.string(),
params: z.unknown().optional(),
});
export type SandboxDaemonRequest = z.output<typeof SandboxDaemonRequestZ>;
export const SandboxDaemonResponseZ = z.union([
z.strictObject({ jsonrpc: z.literal('2.0'), id: z.string(), result: z.unknown().nonoptional() }),
z.strictObject({
jsonrpc: z.literal('2.0'),
id: z.string(),
error: z.strictObject({ code: z.number().int(), message: z.string() }),
}),
]);
export type SandboxDaemonResponse = z.output<typeof SandboxDaemonResponseZ>;
+133 -133
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "24.2.0",
"version": "24.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "24.2.0",
"@expo/eas-json": "24.2.0",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-json": "24.3.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "24.0.0",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.2.0",
"@expo/steps": "24.3.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "24.2.0",
"version": "24.3.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "24.2.0",
"@expo/eas-build-job": "24.3.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "24.2.0",
"version": "24.3.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,9 +26,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "24.2.0",
"@expo/build-tools": "24.3.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "24.2.0",
"@expo/eas-build-job": "24.3.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "24.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "24.2.0",
"version": "24.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "24.2.0",
"@expo/eas-build-job": "24.3.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "24.2.0",
"version": "24.3.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,15 +19,15 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "24.2.0",
"@expo/build-tools": "24.3.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "24.2.0",
"@expo/eas-build-job": "24.3.0",
"@expo/logger": "24.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.2.0",
"@expo/steps": "24.3.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@hapi/boom": "10.0.1",
Generated
+19 -17
View File
@@ -2291,14 +2291,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:24.2.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:24.3.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:24.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2306,7 +2306,7 @@ __metadata:
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.2.0"
"@expo/steps": "npm:24.3.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2324,6 +2324,7 @@ __metadata:
"@types/retry": "npm:^0.12.5"
"@types/semver": "npm:^7.5.8"
"@types/uuid": "npm:^9.0.8"
"@types/ws": "npm:8.5.10"
"@urql/core": "npm:^6.0.1"
bplist-parser: "npm:0.3.2"
content-disposition: "npm:1.0.1"
@@ -2354,6 +2355,7 @@ __metadata:
tslib: "npm:^2.6.3"
typescript: "npm:^5.5.4"
uuid: "npm:^9.0.1"
ws: "npm:8.21.1"
yaml: "npm:^2.8.1"
zod: "npm:^4.3.5"
languageName: unknown
@@ -2557,7 +2559,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:24.2.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:24.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -2576,12 +2578,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:24.2.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:24.3.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/eas-build-job": "npm:24.3.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2895,11 +2897,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:24.2.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:24.3.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:24.2.0"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -2969,15 +2971,15 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:24.2.0"
"@expo/build-tools": "npm:24.3.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/logger": "npm:24.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.2.0"
"@expo/steps": "npm:24.3.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9828,7 +9830,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:24.2.0"
"@expo/steps": "npm:24.3.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10412,9 +10414,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:24.2.0"
"@expo/build-tools": "npm:24.3.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:24.0.0"
@@ -10469,8 +10471,8 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:24.2.0"
"@expo/eas-json": "npm:24.2.0"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-json": "npm:24.3.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:24.0.0"
@@ -10485,7 +10487,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.2.0"
"@expo/steps": "npm:24.3.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"