peachsim 0.1.527 → 0.1.570

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +26 -26
  2. package/cli/app-fonts.ts +1 -1
  3. package/cli/app-splash.ts +1 -1
  4. package/cli/bin.ts +1 -1
  5. package/cli/bridge-diagnostics.ts +1 -1
  6. package/cli/bridge-flow-runner.ts +57 -5
  7. package/cli/cloud-client.ts +273 -6
  8. package/cli/cloud-dispatch.ts +22 -2
  9. package/cli/cloud-session.ts +60 -4
  10. package/cli/cloud-test-tunnel.ts +354 -0
  11. package/cli/commands/box/bundle-plane.ts +2 -2
  12. package/cli/commands/box/checkout-plane.ts +1 -1
  13. package/cli/commands/box/rnx-command.ts +2 -2
  14. package/cli/commands/box/search-command.ts +2 -2
  15. package/cli/commands/box.ts +8 -8
  16. package/cli/commands/compat.ts +1 -1
  17. package/cli/commands/control.ts +22 -3
  18. package/cli/commands/detox.ts +1 -1
  19. package/cli/commands/device.ts +1 -1
  20. package/cli/commands/diagnose.ts +1 -1
  21. package/cli/commands/film.ts +1 -1
  22. package/cli/commands/flow.ts +60 -3
  23. package/cli/commands/inspect/actions.ts +1 -1
  24. package/cli/commands/inspect/core.ts +6 -7
  25. package/cli/commands/inspect/find.ts +2 -1
  26. package/cli/commands/inspect/resolve-target.ts +5 -3
  27. package/cli/commands/inspect/shared.ts +1 -1
  28. package/cli/commands/inspect/wait-event.ts +1 -1
  29. package/cli/commands/inspect/wait-selector.ts +15 -4
  30. package/cli/commands/inspect.ts +19 -14
  31. package/cli/commands/maestro.ts +464 -43
  32. package/cli/commands/permissions.ts +1 -1
  33. package/cli/commands/platform.ts +205 -8
  34. package/cli/commands/preview.ts +2 -2
  35. package/cli/commands/record.ts +2 -2
  36. package/cli/commands/report-issue.ts +2 -2
  37. package/cli/commands/screenshot-layers.ts +1 -1
  38. package/cli/commands/screenshot.ts +1 -1
  39. package/cli/commands/setup.ts +2 -2
  40. package/cli/commands/test.ts +440 -115
  41. package/cli/commands/three-mode.ts +1 -1
  42. package/cli/commands/upload.ts +5 -5
  43. package/cli/commands/what-happened.ts +1 -1
  44. package/cli/desktop-companion.ts +3 -3
  45. package/cli/drivers/playwright-sim-host.ts +2 -2
  46. package/cli/flow-file.ts +7 -0
  47. package/cli/flow-live-status.ts +65 -8
  48. package/cli/help-core.ts +3 -3
  49. package/cli/hidden-runtime-alias.ts +1 -1
  50. package/cli/maestro-report.ts +178 -0
  51. package/cli/main.ts +1 -1
  52. package/cli/outbound-endpoints.ts +185 -136
  53. package/cli/parse-args.ts +1 -1
  54. package/cli/registry.ts +1 -1
  55. package/cli/resolve-assets.ts +1 -1
  56. package/cli/run-registry.ts +8 -0
  57. package/cli/send-to-box.ts +4 -4
  58. package/cli/ws-bridge.ts +3 -5
  59. package/detox/expectations.ts +1 -1
  60. package/detox/gestures.ts +2 -2
  61. package/detox/index.ts +71 -29
  62. package/detox/jest-preset.cjs +3 -3
  63. package/detox/jest-setup-after-env.cjs +1 -1
  64. package/dist-lib/agent-daemon-client.cjs +2 -2
  65. package/dist-lib/agent-events.cjs +1 -1
  66. package/dist-lib/agent-identity.cjs +1 -1
  67. package/dist-lib/agent-sessions.cjs +2 -2
  68. package/dist-lib/attached-projects.cjs +2 -2
  69. package/dist-lib/auth/shared-session.cjs +1 -1
  70. package/dist-lib/backend-origin.cjs +1 -1
  71. package/dist-lib/beta.cjs +1 -1
  72. package/dist-lib/beta.mjs +1 -1
  73. package/dist-lib/bridge-constants.cjs +1 -1
  74. package/dist-lib/bridge-contract-input.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.mjs +1 -1
  76. package/dist-lib/bridge-contract.cjs +1 -1
  77. package/dist-lib/bridge-contract.mjs +1 -1
  78. package/dist-lib/capture-contract.cjs +94 -3
  79. package/dist-lib/capture-contract.mjs +93 -3
  80. package/dist-lib/cli-constants.cjs +1 -1
  81. package/dist-lib/cloud-contract.cjs +1 -1
  82. package/dist-lib/cloud-contract.mjs +1 -1
  83. package/dist-lib/cloud.cjs +2 -2
  84. package/dist-lib/cloud.mjs +2 -2
  85. package/dist-lib/config.cjs +1 -1
  86. package/dist-lib/detox/index.cjs +106 -25
  87. package/dist-lib/detox/jest-preset.cjs +3 -3
  88. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  89. package/dist-lib/home-paths.cjs +1 -1
  90. package/dist-lib/host/bridge-host.cjs +137 -61
  91. package/dist-lib/host/fetch-proxy-handler.cjs +28 -2
  92. package/dist-lib/host/fetch-proxy-overrides.cjs +33 -3
  93. package/dist-lib/host/fetch-proxy-overrides.mjs +30 -2
  94. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  95. package/dist-lib/host/websocket-proxy.cjs +1 -1
  96. package/dist-lib/index.cjs +209 -16
  97. package/dist-lib/jump-to-source-babel.cjs +1 -1
  98. package/dist-lib/jump-to-source-native.cjs +1 -1
  99. package/dist-lib/menu.cjs +2 -2
  100. package/dist-lib/menu.mjs +2 -2
  101. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  102. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  103. package/dist-lib/metro-production-bundle.cjs +3 -3
  104. package/dist-lib/metro-production-bundle.mjs +3 -3
  105. package/dist-lib/metro.cjs +8 -8
  106. package/dist-lib/profiles.cjs +1 -1
  107. package/dist-lib/public-brand.cjs +2 -2
  108. package/dist-lib/react-native-host-modules.cjs +1 -1
  109. package/dist-lib/react-native-host-modules.mjs +1 -1
  110. package/dist-lib/render-mode.cjs +1 -1
  111. package/dist-lib/scripts/dev-server-scanner.cjs +31 -15
  112. package/dist-lib/sdk.cjs +3 -2
  113. package/dist-lib/sdk.mjs +3 -2
  114. package/dist-lib/skills.cjs +362 -163
  115. package/dist-lib/swift-submit.cjs +1 -1
  116. package/dist-lib/swift-submit.mjs +1 -1
  117. package/dist-lib/swift.cjs +583 -16
  118. package/dist-lib/vite.cjs +29 -14
  119. package/package.json +9 -9
  120. package/scripts/dev-server-scanner.ts +41 -14
  121. package/scripts/optional-demo-registry.ts +3 -3
  122. package/skills/contrast/SKILL.md +9 -9
  123. package/skills/rnx-debug/SKILL.md +14 -4
  124. package/skills/rnx-perf/SKILL.md +5 -5
  125. package/skills/rnx-setup/SKILL.md +11 -11
  126. package/skills/rnx-test/SKILL.md +11 -11
  127. package/skills/rnx-visual/SKILL.md +6 -6
  128. package/src/agent-sessions.ts +3 -3
  129. package/src/attached-projects.ts +1 -1
  130. package/src/bridge-contract-input.ts +1 -1
  131. package/src/bridge-contract.ts +23 -5
  132. package/src/capture-contract.ts +145 -3
  133. package/src/config.ts +2 -2
  134. package/src/connect.ts +2 -2
  135. package/src/home-paths.ts +3 -3
  136. package/src/host/bridge-host.ts +2 -2
  137. package/src/host/fetch-proxy-handler.ts +4 -3
  138. package/src/host/fetch-proxy-overrides.ts +41 -0
  139. package/src/host/plane-host.ts +2 -2
  140. package/src/index.ts +3 -0
  141. package/src/metro-plugin.ts +4 -4
  142. package/src/metro-production-bundle.ts +2 -2
  143. package/src/native-seam-manifest.ts +6 -6
  144. package/src/public-brand.ts +1 -1
  145. package/src/rolldown-plugin-swift.ts +8 -5
  146. package/src/runtime-assets.ts +8 -6
  147. package/src/screenshots/frame-compose.ts +24 -56
  148. package/src/screenshots/org-deck.ts +1 -1
  149. package/src/screenshots/registry.ts +4 -4
  150. package/src/screenshots/schema.ts +1 -1
  151. package/src/sdk.ts +2 -2
  152. package/src/settings-bundle.ts +1 -1
  153. package/src/sim-client.ts +2 -4
  154. package/src/skills/builtin/compat-check.ts +1 -1
  155. package/src/vite-plugin-one.ts +1 -1
  156. package/src/vite-plugin-swift.ts +11 -6
  157. package/src/vite-plugin.ts +30 -17
  158. package/src/worklets-babel.ts +2 -2
@@ -23,8 +23,8 @@ import {
23
23
  buildRnxCloudArtifact,
24
24
  type RnxCloudArtifactReceipt,
25
25
  } from '../../contrast-bundler/src/metroCloudArtifact.ts'
26
- import { loadMetroFingerprintRegistry } from '../../sootsim-engine/src/metro-fingerprint-registry-client.ts'
27
- import { inferMetroModuleIdentity } from '../../sootsim-engine/src/metro-fingerprint.ts'
26
+ import { loadMetroFingerprintRegistry } from '../../peach-engine/src/metro-fingerprint-registry-client.ts'
27
+ import { inferMetroModuleIdentity } from '../../peach-engine/src/metro-fingerprint.ts'
28
28
  import { isLoopbackHost } from '../src/backend-origin.ts'
29
29
  import {
30
30
  isRnxCloudCommandType,
@@ -44,17 +44,18 @@ import {
44
44
  type RNXMetroModuleIdentityMetadata,
45
45
  } from '../src/metro-production-bundle.ts'
46
46
  import { rnxPublicBrand } from '../src/public-brand.ts'
47
- import { CHOOSE_ACCOUNT_HINT } from './auth.ts'
47
+ import { CHOOSE_ACCOUNT_HINT, resolveCliAuth } from './auth.ts'
48
48
  import {
49
49
  createCloudSession,
50
50
  normalizeCloudOrigin,
51
51
  readCloudSession,
52
+ type CloudBoxSession,
52
53
  type CloudSession,
53
54
  type CloudSimSession,
54
55
  type RemotePlatform,
55
56
  } from './cloud-session.ts'
56
57
  import { detectProject } from './commands/detect.ts'
57
- import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract.ts'
58
+ import type { StorageSnapshot } from '../../peach-engine/src/preview/storage-snapshot-contract.ts'
58
59
  import type {
59
60
  BridgeClaimResult,
60
61
  BridgeSimInfo,
@@ -69,6 +70,22 @@ export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
69
70
  const REMOTE_COMMAND = 'rnx remote'
70
71
  const PREVIEW_COMMAND = 'rnx preview'
71
72
 
73
+ export class UnsupportedRemoteHostError extends Error {
74
+ readonly code = 'unsupported_host' as const
75
+ readonly platform: string
76
+ readonly host: string
77
+
78
+ constructor(platform: string, host: string, message?: string) {
79
+ super(
80
+ message ??
81
+ `rnx remote ${platform} does not support --${host}: peachsim/cloud only creates iOS simulators`,
82
+ )
83
+ this.name = 'UnsupportedRemoteHostError'
84
+ this.platform = platform
85
+ this.host = host
86
+ }
87
+ }
88
+
72
89
  // a Metro asset dest is a shallow tree of images. a walk that runs past this
73
90
  // is pointed at something else entirely, and its extra entries cannot resolve
74
91
  // an asset anyway.
@@ -1072,7 +1089,7 @@ function readSimCreateResponse(
1072
1089
  // typed 400, never by a cli-side branch.
1073
1090
  export async function createRemoteSim(options: CreateRemoteSimOptions): Promise<{
1074
1091
  receipt: RnxCloudSimCreateResponse
1075
- session: CloudSession
1092
+ session: CloudSimSession
1076
1093
  artifact: RnxCloudArtifactReceipt
1077
1094
  assetsDirectory: string | null
1078
1095
  warnings: string[]
@@ -1320,6 +1337,18 @@ export function resolveCloudSessionForParsed(
1320
1337
  ): CloudSession | null {
1321
1338
  const session = readCloudSession()
1322
1339
  if (!session) return null
1340
+ // a box holds one simulator and names it by the box, so --sim names a box
1341
+ // rather than a sim there and this shell already knows which box it is on.
1342
+ if (session.service === 'box') {
1343
+ if (parsed.simIdSource === 'flag' && parsed.simId !== session.boxId) {
1344
+ throw new Error(
1345
+ `this shell is connected to box ${session.boxId}; ${
1346
+ parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
1347
+ } is not available in this shell`,
1348
+ )
1349
+ }
1350
+ return session
1351
+ }
1323
1352
  if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
1324
1353
  throw new Error(
1325
1354
  `this shell is connected to remote simulator ${session.simId}; ${
@@ -1436,12 +1465,248 @@ class CloudBridge implements WsBridge {
1436
1465
  close(): void {}
1437
1466
  }
1438
1467
 
1468
+ class CloudBoxBridge implements WsBridge {
1469
+ readonly plane = 'cloud' as const
1470
+
1471
+ constructor(private readonly session: CloudBoxSession) {}
1472
+
1473
+ async send(
1474
+ command: { type: string; [key: string]: unknown },
1475
+ options?: { timeoutMs?: number },
1476
+ ): Promise<unknown> {
1477
+ const id = randomUUID()
1478
+ // the wire id is this request's, so the command carries none. `simId` names
1479
+ // a local sim among several and a box has exactly one.
1480
+ const payload: Record<string, unknown> = {}
1481
+ for (const [key, value] of Object.entries(command)) {
1482
+ if (key !== 'id' && key !== 'simId') payload[key] = value
1483
+ }
1484
+ let response: Response
1485
+ try {
1486
+ response = await fetch(
1487
+ `${this.session.apiOrigin}/v1/boxes/${encodeURIComponent(this.session.boxId)}/commands`,
1488
+ {
1489
+ method: 'POST',
1490
+ headers: {
1491
+ authorization: `Bearer ${this.session.boxToken}`,
1492
+ 'content-type': 'application/json',
1493
+ },
1494
+ body: JSON.stringify({ id, command: payload }),
1495
+ ...(options?.timeoutMs
1496
+ ? { signal: AbortSignal.timeout(options.timeoutMs) }
1497
+ : {}),
1498
+ },
1499
+ )
1500
+ } catch (error) {
1501
+ throw new Error(
1502
+ `could not reach box ${this.session.boxId} at ${this.session.apiOrigin}: ${
1503
+ error instanceof Error ? error.message : String(error)
1504
+ }`,
1505
+ )
1506
+ }
1507
+ const text = await response.text()
1508
+ if (!response.ok) throw responseError(response.status, text)
1509
+ let parsed: unknown
1510
+ try {
1511
+ parsed = JSON.parse(text)
1512
+ } catch {
1513
+ throw new Error('box command returned unreadable JSON')
1514
+ }
1515
+ if (!isRecord(parsed) || parsed.id !== id) {
1516
+ throw new Error('box command returned an invalid command receipt')
1517
+ }
1518
+ if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
1519
+ if (!Object.hasOwn(parsed, 'result')) {
1520
+ throw new Error('box command receipt has no result')
1521
+ }
1522
+ return parsed.result
1523
+ }
1524
+
1525
+ listSims(): Promise<BridgeSimInfo[]> {
1526
+ return Promise.reject(new Error('a box does not expose a local simulator list'))
1527
+ }
1528
+
1529
+ focusSim(): Promise<never> {
1530
+ return Promise.reject(new Error('a box simulator has no browser window to focus'))
1531
+ }
1532
+
1533
+ resolveReloadedSim(): Promise<string | null> {
1534
+ // cloud sims carry no local browser host, so there is no lineage to follow
1535
+ return Promise.resolve(null)
1536
+ }
1537
+
1538
+ closeSim(): Promise<never> {
1539
+ return Promise.reject(new Error('close a box simulator with rnx box stop'))
1540
+ }
1541
+
1542
+ claim(): Promise<BridgeClaimResult> {
1543
+ return Promise.reject(
1544
+ new Error('a box simulator is held by its Box page and has nothing to claim'),
1545
+ )
1546
+ }
1547
+
1548
+ close(): void {}
1549
+ }
1550
+
1439
1551
  export function createCloudBridgeForParsed(
1440
1552
  parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'>,
1441
1553
  ): WsBridge | null {
1442
1554
  const session = resolveCloudSessionForParsed(parsed)
1443
1555
  if (!session) return null
1444
- return new CloudBridge(session)
1556
+ return session.service === 'box'
1557
+ ? new CloudBoxBridge(session)
1558
+ : new CloudBridge(session)
1559
+ }
1560
+
1561
+ // stops the box, which ends the simulator it holds. an account api key stops
1562
+ // with its own authority; a session login stops with the login, falling back
1563
+ // to the box token the session already holds.
1564
+ export async function closeCloudBox(session: CloudSession): Promise<void> {
1565
+ if (session.service !== 'box') {
1566
+ if (!session.boxId || !session.boxToken) return
1567
+ const response = await fetch(
1568
+ `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}`,
1569
+ {
1570
+ method: 'DELETE',
1571
+ headers: { authorization: `Bearer ${session.boxToken}` },
1572
+ },
1573
+ )
1574
+ const text = await response.text()
1575
+ if (!response.ok && response.status !== 404) {
1576
+ throw responseError(response.status, text)
1577
+ }
1578
+ return
1579
+ }
1580
+ const auth = resolveCliAuth()
1581
+ let authHeader = `Bearer ${session.boxToken}`
1582
+ if (auth?.kind === 'api-key') {
1583
+ authHeader = `Bearer ${auth.secret}`
1584
+ } else if (auth?.kind === 'session') {
1585
+ authHeader = `Bearer ${auth.token}`
1586
+ }
1587
+ let response: Response
1588
+ try {
1589
+ response = await fetch(
1590
+ `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/stop`,
1591
+ {
1592
+ method: 'POST',
1593
+ headers: { authorization: authHeader },
1594
+ },
1595
+ )
1596
+ } catch (error) {
1597
+ throw new Error(
1598
+ `could not reach box ${session.boxId} at ${session.apiOrigin}: ${
1599
+ error instanceof Error ? error.message : String(error)
1600
+ }`,
1601
+ )
1602
+ }
1603
+ const text = await response.text()
1604
+ if (!response.ok && response.status !== 404) {
1605
+ throw responseError(response.status, text)
1606
+ }
1607
+ }
1608
+
1609
+ export interface BoxDisplayGrant {
1610
+ url: string
1611
+ authToken: string
1612
+ expectedPeer: unknown
1613
+ }
1614
+
1615
+ // how long `rnx remote` waits for a fresh box's page to attach its simulator
1616
+ // before giving up on the watch url. the page boots the bundle after the
1617
+ // browser is up, and a large bundle takes a while to parse.
1618
+ const BOX_DISPLAY_GRANT_WAIT_MS = 180_000
1619
+ const BOX_DISPLAY_GRANT_RETRY_MS = 2_000
1620
+
1621
+ function readBoxDisplayGrant(value: unknown): BoxDisplayGrant {
1622
+ if (!isRecord(value) || !isRecord(value.grant)) {
1623
+ throw new Error('box display-grant returned an invalid grant')
1624
+ }
1625
+ const grant = value.grant
1626
+ if (
1627
+ typeof grant.url !== 'string' ||
1628
+ !grant.url ||
1629
+ typeof grant.authToken !== 'string' ||
1630
+ !grant.authToken ||
1631
+ !isRecord(grant.expectedPeer)
1632
+ ) {
1633
+ throw new Error('box display-grant returned an invalid grant')
1634
+ }
1635
+ return { url: grant.url, authToken: grant.authToken, expectedPeer: grant.expectedPeer }
1636
+ }
1637
+
1638
+ // mints a watch-only display grant from the box and builds the bare shell
1639
+ // watch url for it: the same /sootsim/#remoteEngine fragment the nano service
1640
+ // mints, pointed at the box's display socket instead. the box token stays in
1641
+ // this process; only the grant travels in the fragment.
1642
+ export async function requestBoxWatchUrl(options: {
1643
+ apiOrigin: string
1644
+ boxId: string
1645
+ boxToken: string
1646
+ }): Promise<string> {
1647
+ const started = Date.now()
1648
+ for (;;) {
1649
+ let response: Response
1650
+ try {
1651
+ response = await fetch(
1652
+ `${options.apiOrigin}/v1/boxes/${encodeURIComponent(options.boxId)}/display-grant`,
1653
+ {
1654
+ method: 'POST',
1655
+ headers: {
1656
+ authorization: `Bearer ${options.boxToken}`,
1657
+ 'content-type': 'application/json',
1658
+ },
1659
+ body: '{}',
1660
+ },
1661
+ )
1662
+ } catch (error) {
1663
+ throw new Error(
1664
+ `could not reach box ${options.boxId} at ${options.apiOrigin}: ${
1665
+ error instanceof Error ? error.message : String(error)
1666
+ }`,
1667
+ )
1668
+ }
1669
+ if (response.status === 409) {
1670
+ // the page has not attached its simulator yet. this is the normal state
1671
+ // right after a create, so wait rather than refusing.
1672
+ if (Date.now() - started >= BOX_DISPLAY_GRANT_WAIT_MS) {
1673
+ throw new Error(
1674
+ `box ${options.boxId} has no simulator hosting it yet; try again once its page has booted`,
1675
+ )
1676
+ }
1677
+ await new Promise((resolve) => setTimeout(resolve, BOX_DISPLAY_GRANT_RETRY_MS))
1678
+ continue
1679
+ }
1680
+ const text = await response.text()
1681
+ if (!response.ok) throw responseError(response.status, text)
1682
+ let parsed: unknown
1683
+ try {
1684
+ parsed = JSON.parse(text)
1685
+ } catch {
1686
+ throw new Error('box display-grant returned unreadable JSON')
1687
+ }
1688
+ const grant = readBoxDisplayGrant(parsed)
1689
+ const watch = new URL('/sootsim/', rnxPublicBrand.origin)
1690
+ // the shell boots its stage at its own default device, while the box page
1691
+ // hosts its simulator at the grant's device profile. without ?device= the
1692
+ // tenant refuses the host hello on dimensions and paints nothing — the
1693
+ // same param SimPage and BoxHostPage already set on their stage urls.
1694
+ if (
1695
+ isRecord(grant.expectedPeer) &&
1696
+ typeof grant.expectedPeer.deviceProfile === 'string' &&
1697
+ grant.expectedPeer.deviceProfile
1698
+ ) {
1699
+ watch.searchParams.set('device', grant.expectedPeer.deviceProfile)
1700
+ }
1701
+ watch.hash = `remoteEngine=${encodeURIComponent(
1702
+ JSON.stringify({
1703
+ url: grant.url,
1704
+ authToken: grant.authToken,
1705
+ expectedPeer: grant.expectedPeer,
1706
+ }),
1707
+ )}`
1708
+ return watch.toString()
1709
+ }
1445
1710
  }
1446
1711
 
1447
1712
  function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
@@ -1516,6 +1781,8 @@ export async function closeCloudSession(session: CloudSession): Promise<void> {
1516
1781
  }
1517
1782
  return
1518
1783
  }
1784
+ // box sessions close through closeCloudBox; only sim sessions reach this leg.
1785
+ if (session.service !== 'sim') return
1519
1786
  const response = await fetch(
1520
1787
  `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}`,
1521
1788
  {
@@ -157,9 +157,14 @@ export async function dispatchCloudBoundary({
157
157
  }
158
158
  try {
159
159
  const descriptor = cloudSession.requireCloudSessionShellUpdate()
160
- await cloudClient.closeCloudSession(session)
160
+ if (session.service === 'box') await cloudClient.closeCloudBox(session)
161
+ else await cloudClient.closeCloudSession(session)
161
162
  cloudSession.publishCloudSessionToShell(null, descriptor)
162
- console.log(` closed: remote simulator ${session.simId}`)
163
+ console.log(
164
+ session.service === 'box'
165
+ ? ` closed: remote box ${session.boxId}`
166
+ : ` closed: remote simulator ${session.simId}`,
167
+ )
163
168
  return { handled: true, code: 0 }
164
169
  } catch (error) {
165
170
  console.error(
@@ -169,6 +174,21 @@ export async function dispatchCloudBoundary({
169
174
  }
170
175
  }
171
176
 
177
+ // a box relays the whole bridge vocabulary to the page hosting its
178
+ // simulator, which runs the same shell the local CLI drives. so there is no
179
+ // subset to police: every command below reaches it through the ordinary
180
+ // bridge, which createBridgeFromParsed points at the box. claim is specific
181
+ // to nano.
182
+ if (session.service === 'box') {
183
+ if (command === 'claim') {
184
+ console.error(
185
+ ` rnx ${command} is not available for a box; its simulator is held by the Box page. use rnx box stop to end the box.`,
186
+ )
187
+ return { handled: true, code: 1 }
188
+ }
189
+ return { handled: false }
190
+ }
191
+
172
192
  if (command === 'claim') {
173
193
  try {
174
194
  const descriptor = cloudSession.requireCloudSessionShellUpdate()
@@ -10,9 +10,10 @@ export const RNX_SHELL_UPDATE_PREFIX = '__RNX_SHELL_UPDATE_V1__'
10
10
  export const RNX_SHELL_UPDATE_SET_PREFIX = `${RNX_SHELL_UPDATE_PREFIX}set:`
11
11
  export const RNX_SHELL_UPDATE_UNSET = `${RNX_SHELL_UPDATE_PREFIX}unset`
12
12
 
13
- // the shell's remote target: one simulator on the instance API, addressed by
14
- // its own id and token.
15
- export type CloudSession = CloudSimSession
13
+ // the shell's remote target. two services answer rnx commands and they are
14
+ // addressed differently, so the session names which one it is rather than
15
+ // leaving a reader to infer it from which fields happen to be filled in.
16
+ export type CloudSession = CloudSimSession | CloudBoxSession
16
17
 
17
18
  export type RemotePlatform = 'ios' | 'android'
18
19
 
@@ -43,6 +44,23 @@ export interface CloudSimSession {
43
44
  boxToken?: string
44
45
  }
45
46
 
47
+ /**
48
+ * a micro box, whose simulator lives in its own box page rather than in the
49
+ * service. there is nothing to claim and no separate simulator token: the box
50
+ * token addresses the box, and the box relays each command to the page that
51
+ * is hosting it.
52
+ */
53
+ export interface CloudBoxSession {
54
+ version: 3
55
+ service: 'box'
56
+ boxId: string
57
+ boxToken: string
58
+ apiOrigin: string
59
+ platform?: RemotePlatform
60
+ device?: string
61
+ artifact?: CloudSessionArtifactIdentity
62
+ }
63
+
46
64
  function isRecord(value: unknown): value is Record<string, unknown> {
47
65
  return value !== null && typeof value === 'object' && !Array.isArray(value)
48
66
  }
@@ -106,8 +124,35 @@ export function createCloudSession(input: {
106
124
  return session
107
125
  }
108
126
 
127
+ export function createCloudBoxSession(input: {
128
+ boxId: string
129
+ boxToken: string
130
+ apiOrigin: string
131
+ platform?: RemotePlatform
132
+ device?: string
133
+ artifact?: CloudSessionArtifactIdentity
134
+ }): CloudBoxSession {
135
+ const session: CloudBoxSession = {
136
+ version: CLOUD_SESSION_VERSION,
137
+ service: 'box',
138
+ boxId: input.boxId.trim(),
139
+ boxToken: input.boxToken,
140
+ apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()),
141
+ ...(input.platform ? { platform: input.platform } : {}),
142
+ ...(input.device?.trim() ? { device: input.device.trim() } : {}),
143
+ ...(input.artifact ? { artifact: input.artifact } : {}),
144
+ }
145
+ if (!session.boxId || !session.boxToken) {
146
+ throw new Error('cloud box session requires a box, an origin, and a box token')
147
+ }
148
+ return session
149
+ }
150
+
109
151
  export function serializeCloudSession(session: CloudSession): string {
110
- const validated = createCloudSession(session)
152
+ const validated =
153
+ session.service === 'box'
154
+ ? createCloudBoxSession(session)
155
+ : createCloudSession(session)
111
156
  return Buffer.from(JSON.stringify(validated), 'utf8').toString('base64url')
112
157
  }
113
158
 
@@ -154,6 +199,17 @@ export function parseCloudSession(encoded: string): CloudSession {
154
199
  const boxId = typeof parsed.boxId === 'string' ? parsed.boxId : undefined
155
200
  const boxToken = typeof parsed.boxToken === 'string' ? parsed.boxToken : undefined
156
201
  try {
202
+ if (parsed.service === 'box') {
203
+ if (!boxId || !boxToken) throw new Error('unreadable cloud box session')
204
+ return createCloudBoxSession({
205
+ boxId,
206
+ boxToken,
207
+ apiOrigin,
208
+ platform,
209
+ device,
210
+ artifact,
211
+ })
212
+ }
157
213
  const simId = parsed.simId
158
214
  const claimId = parsed.claimId
159
215
  const token = parsed.token