// typed Remote Simulators client for agents. the CLI produces artifact bytes // (local file or a fetched URL); this module talks to the service in those // bytes only. command types and receipts are the same ones the CLI already // sends. do not invent a second vocabulary. // // one call gives you one remote simulator. it runs on a nano box today, which // is why the session below carries a box id and the create request asks for a // size, but that is the service's business: nothing box-shaped reaches the // public surface. import { isLoopbackHost } from './backend-origin' import { isRnxScreenCapture, type RnxScreenCapture } from './capture-contract' import { isRnxCloudCommandScope, isRnxCloudCommandType, RNX_CLOUD_COMMAND_TYPES, RNX_CLOUD_MAX_ARTIFACT_BYTES, RNX_CLOUD_SCOPED_TOKEN_PREFIX, RNX_CLOUD_TOKEN_SCOPES, rnxCloudCommandScope, rnxCloudScopesAllowCommand, type RnxCloudBoxCreateReceipt, type RnxCloudClaim, type RnxCloudCommandScope, type RnxCloudCreateReceipt, type RnxCloudScopedToken, } from './cloud-contract' import { rnxPublicBrand } from './public-brand' import type { SimStateOptions } from './bridge-contract' export { isRnxCloudCommandScope, isRnxCloudCommandType, RNX_CLOUD_COMMAND_TYPES, RNX_CLOUD_MAX_ARTIFACT_BYTES, RNX_CLOUD_SCOPED_TOKEN_PREFIX, } export type { RnxCloudClaim, RnxCloudCommandScope, RnxCloudCreateReceipt, RnxCloudScopedToken, RnxScreenCapture, } const DEFAULT_DEVICE = 'iphone-16' const DEFAULT_WAIT_READY_MS = 20_000 // how many times a request goes back after a retryable refusal or a server // error. the sim service passes the account's own 429 bodies through, so the // code on the body is what decides: `rate_limited` carries a `retry-after` // and is worth waiting for, while `concurrency_limit` refuses until the // customer stops something and retrying only hammers a closed door. // `early_access` and 402 never change on retry either. const CLOUD_MAX_RETRIES = 2 const CLOUD_NEVER_RETRY_CODES = new Set(['concurrency_limit', 'early_access']) export type RemoteCommand = { type: (typeof RNX_CLOUD_COMMAND_TYPES)[number] [key: string]: unknown } export interface CreateRemoteSimulatorInput { artifact: Uint8Array authorization: string origin?: string device?: string sha256?: string } // attach to a simulator another process created. the token is the full // per-simulator token or a scoped `sk_rnx_sim_` token holding the scopes the // verbs below need. export interface ConnectRemoteSimulatorInput { id: string token: string origin?: string } // the simulator-driving verbs. the same vocabulary the CLI drives, one for // one: `rnx describe`, `rnx do tap`, `rnx wait selector`. export interface RnxCommands { command( command: { type: string; [key: string]: unknown }, options?: { timeoutMs?: number }, ): Promise describe(): Promise do(command: { type: string; [key: string]: unknown }): Promise wait( condition: 'ready' | { type: 'selector'; selector: string }, options?: { maxMs?: number }, ): Promise // a `data:image/png;base64,` url, assembled by the service from the frame screenshot(options?: { layers?: 'full' | 'tenant' | 'shell' }): Promise capture(): Promise // data wipes the app's stores and cold-remounts, keeping install-scoped // credentials; full clears those too. from boots the wiped simulator on a // wholesale live copy of another simulator's storage instead of empty // stores. the bundle itself is never reinstalled: artifacts are immutable // and sha-addressed, so there is no install to redo. reset(options?: { strategy?: 'data' | 'full'; from?: string }): Promise // the one-call read: screenshot, tree, route, and recent errors together. state(options?: SimStateOptions): Promise } interface RemoteSimulatorSession { origin: string // present on the create path only. a connected simulator never learned its // box, so closing one stops the simulator rather than its box. boxId: string | null boxToken: string | null simId: string simulatorToken: string // null until the simulator reports one. connect takes no claim and steals // none, so an unconfirmed simulator attaches with none. claim: RnxCloudClaim | null artifact: RnxCloudCreateReceipt['artifact'] // what this token may send. the create path carries every scope except // debug; a connected simulator carries what its token was minted with, and // verbs outside it are refused before any request. scopes: readonly RnxCloudCommandScope[] } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } function normalizeCloudOrigin(source: string): string { let parsed: URL try { parsed = new URL(source) } catch { throw new Error(`invalid RNX Cloud origin: ${source}`) } if ( (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash ) { throw new Error(`RNX Cloud origin must be an HTTP(S) origin: ${source}`) } if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) { throw new Error(`RNX Cloud origin requires HTTPS outside loopback: ${source}`) } return parsed.origin } function resolveOrigin(value?: string): string { const configured = value?.trim() || process.env.RNX_CLOUD_ORIGIN?.trim() return normalizeCloudOrigin(configured || rnxPublicBrand.origin) } function responseError(status: number, text: string): Error { if (text) { try { const parsed: unknown = JSON.parse(text) const error = isRecord(parsed) ? parsed.error : null if (isRecord(error) && typeof error.message === 'string') { return new Error(`RNX Cloud request failed (${status}): ${error.message}`) } } catch { // body was not json; fall through to the raw text } } return new Error( `RNX Cloud request failed (${status})${text.trim() ? `: ${text.trim()}` : ''}`, ) } function readCloudErrorCode(text: string): string | null { // the account api answers `{ error: 'code', message }` and the sim service // `{ error: { code, message } }`; both reach this client, so both read. let parsed: unknown try { parsed = JSON.parse(text) } catch { return null } if (!isRecord(parsed)) return null if (typeof parsed.error === 'string') return parsed.error if (isRecord(parsed.error) && typeof parsed.error.code === 'string') { return parsed.error.code } return null } function readRetryAfterMs(response: Response, text: string): number | null { const header = response.headers.get('retry-after') if (header !== null) { const seconds = Number(header.trim()) if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000 } let parsed: unknown try { parsed = JSON.parse(text) } catch { return null } if ( isRecord(parsed) && typeof parsed.retryAfterMs === 'number' && Number.isFinite(parsed.retryAfterMs) && parsed.retryAfterMs >= 0 ) { return parsed.retryAfterMs } return null } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } // one fetch with the cloud retry policy: `rate_limited` waits out its // `retry-after` and 5xx goes again at once, each at most twice, and nothing // else is retried. the body is buffered only on a retryable status, so every // other refusal reaches the caller on its original response. async function fetchWithCloudRetry( url: string, init: RequestInit, options?: { timeoutMs?: number }, ): Promise { let attempt = 0 for (;;) { const response = await fetch( url, options?.timeoutMs === undefined ? init : { ...init, signal: AbortSignal.timeout(options.timeoutMs) }, ) if (response.ok) return response const retryableStatus = response.status === 429 || response.status >= 500 if (!retryableStatus || attempt >= CLOUD_MAX_RETRIES) return response const text = await response.text() const code = readCloudErrorCode(text) const retryable = response.status >= 500 ? !(code !== null && CLOUD_NEVER_RETRY_CODES.has(code)) : code === 'rate_limited' if (!retryable) { return new Response(text, { status: response.status, headers: response.headers }) } if (response.status === 429) { await sleep(readRetryAfterMs(response, text) ?? 1000) } attempt += 1 } } function copyArrayBuffer(bytes: Uint8Array): ArrayBuffer { const copy = new ArrayBuffer(bytes.byteLength) new Uint8Array(copy).set(bytes) return copy } async function sha256Hex(bytes: Uint8Array): Promise { const digest = await globalThis.crypto.subtle.digest('SHA-256', copyArrayBuffer(bytes)) const view = new Uint8Array(digest) let hex = '' for (const byte of view) hex += byte.toString(16).padStart(2, '0') return hex } function readJsArtifact(bytes: Uint8Array): string { let source: string try { source = new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { throw new Error('rnx Cloud artifact must be valid UTF-8 JavaScript, not bytecode') } if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) { throw new Error('rnx Cloud artifact must be a Metro JavaScript bundle') } return source } function readClaim(value: unknown): RnxCloudClaim { if ( !isRecord(value) || typeof value.id !== 'string' || !value.id || typeof value.expiresAt !== 'number' ) { throw new Error('RNX Cloud returned an invalid claim') } return { id: value.id, expiresAt: value.expiresAt } } function artifactDigestId(sha256: string): `sha256:${string}` { return `sha256:${sha256}` } function readArtifact( value: unknown, expectedId: `sha256:${string}`, expectedBytes: number, ): RnxCloudBoxCreateReceipt['artifact'] { if ( !isRecord(value) || value.id !== expectedId || typeof value.bytes !== 'number' || !Number.isInteger(value.bytes) || value.bytes !== expectedBytes ) { throw new Error('RNX Cloud returned an invalid artifact receipt') } return { id: expectedId, bytes: expectedBytes } } function readSimulatorCreate( value: unknown, expectedId: `sha256:${string}`, expectedBytes: number, ): { simId: string token: string claim: RnxCloudClaim artifact: RnxCloudBoxCreateReceipt['artifact'] } { if (!isRecord(value)) throw new Error('RNX Cloud create returned an invalid simulator') const claim = readClaim(value.claim) if ( typeof value.simId !== 'string' || !value.simId || typeof value.token !== 'string' ) { throw new Error('RNX Cloud create returned an invalid simulator') } return { simId: value.simId, token: value.token, claim, artifact: readArtifact(value.artifact, expectedId, expectedBytes), } } function readBoxCreate( value: unknown, expectedBytes: number, expectedSha256: string, ): { receipt: RnxCloudBoxCreateReceipt boxToken: string simulatorToken: string } { const artifactId = artifactDigestId(expectedSha256) if (!isRecord(value)) throw new Error('RNX Cloud create returned invalid JSON') const simulator = readSimulatorCreate(value.simulator, artifactId, expectedBytes) if ( typeof value.boxId !== 'string' || !value.boxId || value.size !== 'nano' || typeof value.token !== 'string' || !value.token ) { throw new Error('RNX Cloud create returned an invalid box receipt') } return { receipt: { boxId: value.boxId, size: 'nano', artifact: readArtifact(value.artifact, artifactId, expectedBytes), simulator: { simId: simulator.simId, artifact: simulator.artifact, claim: simulator.claim, }, }, boxToken: value.token, simulatorToken: simulator.token, } } function cloudCommand( command: { type: string; [key: string]: unknown }, scopes: readonly RnxCloudCommandScope[], ): RemoteCommand { const clean: Record = {} for (const [key, value] of Object.entries(command)) { if (key !== 'id' && key !== 'simId') clean[key] = value } if (!isRnxCloudCommandType(command.type)) { throw new Error(`RNX Cloud does not support bridge command type ${command.type}`) } // a verb outside this token's scopes never leaves the sdk: the refusal // names the scope before a request. the full token carries every scope // except debug, so evaluate/call/close stop here for it. if (!rnxCloudScopesAllowCommand(scopes, command.type)) { throw new Error( `RNX Cloud command ${command.type} requires the ${rnxCloudCommandScope(command.type)} scope`, ) } return { ...clean, type: command.type } } export class RemoteSimulator { private session: RemoteSimulatorSession // driving verbs live here so the client reads the way the CLI does. bound to // this simulator at construction, and every verb reads the session live, so a // confirmed claim reaches commands issued after it. readonly rnx: RnxCommands constructor(session: RemoteSimulatorSession) { this.session = session this.rnx = { command: (command, options) => this.sendCommand(command, options), describe: () => this.sendCommand({ type: 'tree' }), do: (command) => this.sendCommand(command), wait: (condition, options) => { const timeoutMs = options?.maxMs ?? DEFAULT_WAIT_READY_MS const waitCondition = condition === 'ready' ? { type: 'ready' } : condition return this.sendCommand( { type: 'waitFor', waitForOptions: { condition: waitCondition, timeoutMs }, }, { timeoutMs: timeoutMs + 1_000 }, ) }, screenshot: async (options) => { const result = await this.sendCommand({ type: 'screenshot', layers: options?.layers ?? 'tenant', }) if (typeof result !== 'string' || !result.startsWith('data:image/')) { throw new Error('RNX Cloud screenshot did not return an image data url') } return result }, capture: async () => { const result = await this.sendCommand({ type: 'capture' }) if (!isRnxScreenCapture(result)) { throw new Error('RNX Cloud capture returned an invalid screen capture') } return result }, reset: (options) => { if ( options?.strategy !== undefined && options.strategy !== 'data' && options.strategy !== 'full' ) { throw new Error('RNX Cloud reset strategy must be data or full') } if ( options?.from !== undefined && (typeof options.from !== 'string' || options.from.length === 0) ) { throw new Error('RNX Cloud reset from must name a simulator') } return this.sendCommand( options === undefined ? { type: 'reset' } : { type: 'reset', resetOptions: { ...(options.strategy === undefined ? {} : { strategy: options.strategy }), ...(options.from === undefined ? {} : { from: options.from }), }, }, ) }, state: (options) => this.sendCommand( options === undefined ? { type: 'state' } : { type: 'state', stateOptions: options }, ), } } get simId(): string { return this.session.simId } get claim(): RnxCloudClaim | null { return this.session.claim } get scopes(): readonly RnxCloudCommandScope[] { return this.session.scopes } get receipt(): RnxCloudCreateReceipt { if (!this.session.claim) { throw new Error('this simulator was attached with connect() and holds no claim') } return { simId: this.session.simId, artifact: this.session.artifact, claim: this.session.claim, } } private async sendCommand( command: { type: string; [key: string]: unknown }, options?: { timeoutMs?: number }, ): Promise { const body = { id: globalThis.crypto.randomUUID(), // a scoped token drives without a claim: the service checks the token // instead. a full token still joins the claim it names here. claimId: this.session.claim?.id ?? '', command: cloudCommand(command, this.session.scopes), } let response: Response try { response = await fetchWithCloudRetry( `${this.session.origin}/v1/sims/${encodeURIComponent(this.session.simId)}/commands`, { method: 'POST', headers: { authorization: `Bearer ${this.session.simulatorToken}`, 'content-type': 'application/json', }, body: JSON.stringify(body), }, options?.timeoutMs === undefined ? undefined : { timeoutMs: options.timeoutMs }, ) } catch (error) { throw new Error( `could not reach RNX Cloud at ${this.session.origin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const text = await response.text() if (!response.ok) throw responseError(response.status, text) let parsed: unknown try { parsed = JSON.parse(text) } catch { throw new Error('RNX Cloud command returned unreadable JSON') } if (!isRecord(parsed) || parsed.id !== body.id) { throw new Error('RNX Cloud command returned an invalid command receipt') } if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error) if (!Object.hasOwn(parsed, 'result')) { throw new Error('RNX Cloud command receipt has no result') } return parsed.result } async confirm(): Promise { if (!this.session.claim) { throw new Error('this simulator was attached with connect() and holds no claim') } const claimId = this.session.claim.id const response = await fetchWithCloudRetry( `${this.session.origin}/v1/sims/${encodeURIComponent(this.session.simId)}/claim`, { method: 'POST', headers: { authorization: `Bearer ${this.session.simulatorToken}`, 'content-type': 'application/json', }, body: JSON.stringify({ claimId }), }, ) const text = await response.text() if (!response.ok) throw responseError(response.status, text) let parsed: unknown try { parsed = JSON.parse(text) } catch { throw new Error('RNX Cloud claim returned unreadable JSON') } const claim = readClaim(isRecord(parsed) ? parsed.claim : null) if (claim.id !== claimId) { throw new Error('RNX Cloud confirmation replaced the initial claim') } this.session = { ...this.session, claim } return claim } async close(): Promise { // the create path tears down the box it made. a connected simulator never // learned its box, so it stops its own simulator instead; a scoped token // is refused there, which is the service telling the holder it may drive // but never delete. const target = this.session.boxId !== null && this.session.boxToken !== null ? { url: `${this.session.origin}/v1/boxes/${encodeURIComponent(this.session.boxId)}`, authorization: `Bearer ${this.session.boxToken}`, } : { url: `${this.session.origin}/v1/sims/${encodeURIComponent(this.session.simId)}`, authorization: `Bearer ${this.session.simulatorToken}`, } const response = await fetchWithCloudRetry(target.url, { method: 'DELETE', headers: { authorization: target.authorization }, }) const text = await response.text() if (!response.ok && response.status !== 404) { throw responseError(response.status, text) } } } export async function createRemoteSimulator( input: CreateRemoteSimulatorInput, ): Promise { if (input.artifact.byteLength > RNX_CLOUD_MAX_ARTIFACT_BYTES) { throw new Error( `rnx Cloud artifact is ${input.artifact.byteLength} bytes and exceeds the ${RNX_CLOUD_MAX_ARTIFACT_BYTES}-byte limit`, ) } if (input.artifact.byteLength <= 0) { throw new Error('rnx Cloud artifact is empty') } readJsArtifact(input.artifact) const origin = resolveOrigin(input.origin) const sha256 = await sha256Hex(input.artifact) if (input.sha256 && input.sha256 !== sha256) { throw new Error('rnx Cloud artifact sha256 does not match the provided digest') } const device = input.device?.trim() || DEFAULT_DEVICE let response: Response try { response = await fetchWithCloudRetry(`${origin}/v1/boxes`, { method: 'POST', headers: { authorization: input.authorization, 'content-type': 'application/javascript', 'x-rnx-box-size': 'nano', 'x-rnx-artifact-sha256': sha256, 'x-rnx-platform': 'ios', 'x-rnx-device': device, }, body: copyArrayBuffer(input.artifact), }) } catch (error) { throw new Error( `could not reach RNX Cloud at ${origin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const responseText = await response.text() if (!response.ok) throw responseError(response.status, responseText) let parsed: unknown try { parsed = JSON.parse(responseText) } catch { throw new Error('RNX Cloud create returned unreadable JSON') } const created = readBoxCreate(parsed, input.artifact.byteLength, sha256) const simulator = new RemoteSimulator({ origin, boxId: created.receipt.boxId, boxToken: created.boxToken, simId: created.receipt.simulator.simId, simulatorToken: created.simulatorToken, claim: created.receipt.simulator.claim, artifact: created.receipt.artifact, scopes: RNX_CLOUD_TOKEN_SCOPES, }) await simulator.confirm() return simulator } function readSimArtifact(value: unknown): RnxCloudCreateReceipt['artifact'] { if ( !isRecord(value) || typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || typeof value.bytes !== 'number' || !Number.isInteger(value.bytes) || value.bytes <= 0 ) { throw new Error('RNX Cloud returned an invalid simulator artifact') } return { id: `sha256:${value.sha256}`, bytes: value.bytes } } function readConnectClaim(value: unknown): RnxCloudClaim | null { if (value === null) return null return readClaim(value) } function readConnectScopes( value: unknown, token: string, ): readonly RnxCloudCommandScope[] { // the full per-simulator token carries every scope except debug, which the // service already guarantees. a scoped token learns its verbs from the get // it just made, so a verb outside them is refused before any request. if (!token.startsWith(RNX_CLOUD_SCOPED_TOKEN_PREFIX)) return RNX_CLOUD_TOKEN_SCOPES if ( !isRecord(value) || typeof value.id !== 'string' || !value.id || !Array.isArray(value.scopes) || value.scopes.length === 0 || !value.scopes.every( (scope): scope is RnxCloudCommandScope => typeof scope === 'string' && isRnxCloudCommandScope(scope), ) || typeof value.expiresAt !== 'number' ) { throw new Error('RNX Cloud returned an invalid scoped token') } return Object.freeze([...value.scopes]) } // attach to a simulator another process created. one get proves the token, // and the simulator it returns drives with that token's scopes, takes no // claim, and never steals one. export async function connect( input: ConnectRemoteSimulatorInput, ): Promise { if (!input.id) throw new Error('RNX Cloud connect needs a simulator id') if (!input.token) throw new Error('RNX Cloud connect needs a token') const origin = resolveOrigin(input.origin) let response: Response try { response = await fetchWithCloudRetry( `${origin}/v1/sims/${encodeURIComponent(input.id)}`, { headers: { authorization: `Bearer ${input.token}` } }, ) } catch (error) { throw new Error( `could not reach RNX Cloud at ${origin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const text = await response.text() if (!response.ok) throw responseError(response.status, text) let parsed: unknown try { parsed = JSON.parse(text) } catch { throw new Error('RNX Cloud connect returned unreadable JSON') } if (!isRecord(parsed) || !isRecord(parsed.sim)) { throw new Error('RNX Cloud connect returned an invalid simulator') } const sim = parsed.sim if (typeof sim.simId !== 'string' || !sim.simId) { throw new Error('RNX Cloud connect returned an invalid simulator') } return new RemoteSimulator({ origin, boxId: null, boxToken: null, simId: sim.simId, simulatorToken: input.token, claim: readConnectClaim(sim.claim ?? null), artifact: readSimArtifact(sim.artifact), scopes: readConnectScopes(isRecord(parsed) ? parsed.token : null, input.token), }) }