rnxsim 0.1.516 → 0.1.518

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 (56) hide show
  1. package/detox/index.ts +56 -31
  2. package/dist-lib/agent-daemon-client.cjs +1 -1
  3. package/dist-lib/agent-events.cjs +1 -1
  4. package/dist-lib/agent-identity.cjs +1 -1
  5. package/dist-lib/agent-sessions.cjs +1 -1
  6. package/dist-lib/attached-projects.cjs +1 -1
  7. package/dist-lib/auth/shared-session.cjs +1 -1
  8. package/dist-lib/backend-origin.cjs +1 -1
  9. package/dist-lib/beta.cjs +1 -1
  10. package/dist-lib/beta.mjs +1 -1
  11. package/dist-lib/bridge-constants.cjs +1 -1
  12. package/dist-lib/bridge-contract-input.cjs +1 -1
  13. package/dist-lib/bridge-contract-input.mjs +1 -1
  14. package/dist-lib/bridge-contract.cjs +1 -1
  15. package/dist-lib/bridge-contract.mjs +1 -1
  16. package/dist-lib/capture-contract.cjs +1 -1
  17. package/dist-lib/capture-contract.mjs +1 -1
  18. package/dist-lib/cli-constants.cjs +1 -1
  19. package/dist-lib/cloud-contract.cjs +29 -1
  20. package/dist-lib/cloud-contract.mjs +23 -1
  21. package/dist-lib/cloud.cjs +114 -16
  22. package/dist-lib/cloud.mjs +111 -16
  23. package/dist-lib/config.cjs +1 -1
  24. package/dist-lib/detox/index.cjs +30 -14
  25. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  26. package/dist-lib/home-paths.cjs +1 -1
  27. package/dist-lib/host/bridge-host.cjs +1 -1
  28. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  29. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  30. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  31. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  32. package/dist-lib/host/websocket-proxy.cjs +1 -1
  33. package/dist-lib/index.cjs +1 -1
  34. package/dist-lib/jump-to-source-babel.cjs +1 -1
  35. package/dist-lib/jump-to-source-native.cjs +1 -1
  36. package/dist-lib/menu.cjs +1 -1
  37. package/dist-lib/menu.mjs +1 -1
  38. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  39. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  40. package/dist-lib/metro-production-bundle.cjs +1 -1
  41. package/dist-lib/metro-production-bundle.mjs +1 -1
  42. package/dist-lib/metro.cjs +1 -1
  43. package/dist-lib/profiles.cjs +1 -1
  44. package/dist-lib/public-brand.cjs +1 -1
  45. package/dist-lib/react-native-host-modules.cjs +1 -1
  46. package/dist-lib/react-native-host-modules.mjs +1 -1
  47. package/dist-lib/render-mode.cjs +1 -1
  48. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  49. package/dist-lib/sdk.cjs +1 -1
  50. package/dist-lib/sdk.mjs +1 -1
  51. package/dist-lib/skills.cjs +1 -1
  52. package/dist-lib/swift.cjs +1 -1
  53. package/dist-lib/vite.cjs +1 -1
  54. package/package.json +1 -1
  55. package/src/cloud-contract.ts +37 -1
  56. package/src/cloud.ts +178 -21
package/src/cloud.ts CHANGED
@@ -9,22 +9,39 @@
9
9
  // public surface.
10
10
 
11
11
  import { isLoopbackHost } from './backend-origin'
12
+ import type { SimStateOptions } from './bridge-contract'
12
13
  import { isRnxScreenCapture, type RnxScreenCapture } from './capture-contract'
13
14
  import {
15
+ isRnxCloudCommandScope,
14
16
  isRnxCloudCommandType,
15
17
  RNX_CLOUD_COMMAND_TYPES,
16
18
  RNX_CLOUD_MAX_ARTIFACT_BYTES,
19
+ RNX_CLOUD_SCOPED_TOKEN_PREFIX,
17
20
  RNX_CLOUD_TOKEN_SCOPES,
18
21
  rnxCloudCommandScope,
19
22
  rnxCloudScopesAllowCommand,
20
23
  type RnxCloudBoxCreateReceipt,
21
24
  type RnxCloudClaim,
25
+ type RnxCloudCommandScope,
22
26
  type RnxCloudCreateReceipt,
27
+ type RnxCloudScopedToken,
23
28
  } from './cloud-contract'
24
29
  import { rnxPublicBrand } from './public-brand'
25
30
 
26
- export { isRnxCloudCommandType, RNX_CLOUD_COMMAND_TYPES, RNX_CLOUD_MAX_ARTIFACT_BYTES }
27
- export type { RnxCloudClaim, RnxCloudCreateReceipt, RnxScreenCapture }
31
+ export {
32
+ isRnxCloudCommandScope,
33
+ isRnxCloudCommandType,
34
+ RNX_CLOUD_COMMAND_TYPES,
35
+ RNX_CLOUD_MAX_ARTIFACT_BYTES,
36
+ RNX_CLOUD_SCOPED_TOKEN_PREFIX,
37
+ }
38
+ export type {
39
+ RnxCloudClaim,
40
+ RnxCloudCommandScope,
41
+ RnxCloudCreateReceipt,
42
+ RnxCloudScopedToken,
43
+ RnxScreenCapture,
44
+ }
28
45
 
29
46
  const DEFAULT_DEVICE = 'iphone-16'
30
47
  const DEFAULT_WAIT_READY_MS = 20_000
@@ -51,6 +68,15 @@ export interface CreateRemoteSimulatorInput {
51
68
  sha256?: string
52
69
  }
53
70
 
71
+ // attach to a simulator another process created. the token is the full
72
+ // per-simulator token or a scoped `sk_rnx_sim_` token holding the scopes the
73
+ // verbs below need.
74
+ export interface ConnectRemoteSimulatorInput {
75
+ id: string
76
+ token: string
77
+ origin?: string
78
+ }
79
+
54
80
  // the simulator-driving verbs. the same vocabulary the CLI drives, one for
55
81
  // one: `rnx describe`, `rnx do tap`, `rnx wait selector`.
56
82
  export interface RnxCommands {
@@ -68,16 +94,26 @@ export interface RnxCommands {
68
94
  screenshot(options?: { layers?: 'full' | 'tenant' | 'shell' }): Promise<string>
69
95
  capture(): Promise<RnxScreenCapture>
70
96
  reset(): Promise<unknown>
97
+ // the one-call read: screenshot, tree, route, and recent errors together.
98
+ state(options?: SimStateOptions): Promise<unknown>
71
99
  }
72
100
 
73
101
  interface RemoteSimulatorSession {
74
102
  origin: string
75
- boxId: string
76
- boxToken: string
103
+ // present on the create path only. a connected simulator never learned its
104
+ // box, so closing one stops the simulator rather than its box.
105
+ boxId: string | null
106
+ boxToken: string | null
77
107
  simId: string
78
108
  simulatorToken: string
79
- claim: RnxCloudClaim
109
+ // null until the simulator reports one. connect takes no claim and steals
110
+ // none, so an unconfirmed simulator attaches with none.
111
+ claim: RnxCloudClaim | null
80
112
  artifact: RnxCloudCreateReceipt['artifact']
113
+ // what this token may send. the create path carries every scope except
114
+ // debug; a connected simulator carries what its token was minted with, and
115
+ // verbs outside it are refused before any request.
116
+ scopes: readonly RnxCloudCommandScope[]
81
117
  }
82
118
 
83
119
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -333,7 +369,10 @@ function readBoxCreate(
333
369
  }
334
370
  }
335
371
 
336
- function cloudCommand(command: { type: string; [key: string]: unknown }): RemoteCommand {
372
+ function cloudCommand(
373
+ command: { type: string; [key: string]: unknown },
374
+ scopes: readonly RnxCloudCommandScope[],
375
+ ): RemoteCommand {
337
376
  const clean: Record<string, unknown> = {}
338
377
  for (const [key, value] of Object.entries(command)) {
339
378
  if (key !== 'id' && key !== 'simId') clean[key] = value
@@ -341,9 +380,10 @@ function cloudCommand(command: { type: string; [key: string]: unknown }): Remote
341
380
  if (!isRnxCloudCommandType(command.type)) {
342
381
  throw new Error(`RNX Cloud does not support bridge command type ${command.type}`)
343
382
  }
344
- // the customer sdk never carries debug: evaluate/call/close belong to the
345
- // account api key caller. refusing here names the scope before a request.
346
- if (!rnxCloudScopesAllowCommand(RNX_CLOUD_TOKEN_SCOPES, command.type)) {
383
+ // a verb outside this token's scopes never leaves the sdk: the refusal
384
+ // names the scope before a request. the full token carries every scope
385
+ // except debug, so evaluate/call/close stop here for it.
386
+ if (!rnxCloudScopesAllowCommand(scopes, command.type)) {
347
387
  throw new Error(
348
388
  `RNX Cloud command ${command.type} requires the ${rnxCloudCommandScope(command.type)} scope`,
349
389
  )
@@ -394,6 +434,10 @@ export class RemoteSimulator {
394
434
  return result
395
435
  },
396
436
  reset: () => this.sendCommand({ type: 'reset' }),
437
+ state: (options) =>
438
+ this.sendCommand(
439
+ options === undefined ? { type: 'state' } : { type: 'state', stateOptions: options },
440
+ ),
397
441
  }
398
442
  }
399
443
 
@@ -401,11 +445,18 @@ export class RemoteSimulator {
401
445
  return this.session.simId
402
446
  }
403
447
 
404
- get claim(): RnxCloudClaim {
448
+ get claim(): RnxCloudClaim | null {
405
449
  return this.session.claim
406
450
  }
407
451
 
452
+ get scopes(): readonly RnxCloudCommandScope[] {
453
+ return this.session.scopes
454
+ }
455
+
408
456
  get receipt(): RnxCloudCreateReceipt {
457
+ if (!this.session.claim) {
458
+ throw new Error('this simulator was attached with connect() and holds no claim')
459
+ }
409
460
  return {
410
461
  simId: this.session.simId,
411
462
  artifact: this.session.artifact,
@@ -419,8 +470,10 @@ export class RemoteSimulator {
419
470
  ): Promise<unknown> {
420
471
  const body = {
421
472
  id: globalThis.crypto.randomUUID(),
422
- claimId: this.session.claim.id,
423
- command: cloudCommand(command),
473
+ // a scoped token drives without a claim: the service checks the token
474
+ // instead. a full token still joins the claim it names here.
475
+ claimId: this.session.claim?.id ?? '',
476
+ command: cloudCommand(command, this.session.scopes),
424
477
  }
425
478
  let response: Response
426
479
  try {
@@ -462,6 +515,10 @@ export class RemoteSimulator {
462
515
  }
463
516
 
464
517
  async confirm(): Promise<RnxCloudClaim> {
518
+ if (!this.session.claim) {
519
+ throw new Error('this simulator was attached with connect() and holds no claim')
520
+ }
521
+ const claimId = this.session.claim.id
465
522
  const response = await fetchWithCloudRetry(
466
523
  `${this.session.origin}/v1/sims/${encodeURIComponent(this.session.simId)}/claim`,
467
524
  {
@@ -470,7 +527,7 @@ export class RemoteSimulator {
470
527
  authorization: `Bearer ${this.session.simulatorToken}`,
471
528
  'content-type': 'application/json',
472
529
  },
473
- body: JSON.stringify({ claimId: this.session.claim.id }),
530
+ body: JSON.stringify({ claimId }),
474
531
  },
475
532
  )
476
533
  const text = await response.text()
@@ -482,7 +539,7 @@ export class RemoteSimulator {
482
539
  throw new Error('RNX Cloud claim returned unreadable JSON')
483
540
  }
484
541
  const claim = readClaim(isRecord(parsed) ? parsed.claim : null)
485
- if (claim.id !== this.session.claim.id) {
542
+ if (claim.id !== claimId) {
486
543
  throw new Error('RNX Cloud confirmation replaced the initial claim')
487
544
  }
488
545
  this.session = { ...this.session, claim }
@@ -490,13 +547,24 @@ export class RemoteSimulator {
490
547
  }
491
548
 
492
549
  async close(): Promise<void> {
493
- const response = await fetchWithCloudRetry(
494
- `${this.session.origin}/v1/boxes/${encodeURIComponent(this.session.boxId)}`,
495
- {
496
- method: 'DELETE',
497
- headers: { authorization: `Bearer ${this.session.boxToken}` },
498
- },
499
- )
550
+ // the create path tears down the box it made. a connected simulator never
551
+ // learned its box, so it stops its own simulator instead; a scoped token
552
+ // is refused there, which is the service telling the holder it may drive
553
+ // but never delete.
554
+ const target =
555
+ this.session.boxId !== null && this.session.boxToken !== null
556
+ ? {
557
+ url: `${this.session.origin}/v1/boxes/${encodeURIComponent(this.session.boxId)}`,
558
+ authorization: `Bearer ${this.session.boxToken}`,
559
+ }
560
+ : {
561
+ url: `${this.session.origin}/v1/sims/${encodeURIComponent(this.session.simId)}`,
562
+ authorization: `Bearer ${this.session.simulatorToken}`,
563
+ }
564
+ const response = await fetchWithCloudRetry(target.url, {
565
+ method: 'DELETE',
566
+ headers: { authorization: target.authorization },
567
+ })
500
568
  const text = await response.text()
501
569
  if (!response.ok && response.status !== 404) {
502
570
  throw responseError(response.status, text)
@@ -560,7 +628,96 @@ export async function createRemoteSimulator(
560
628
  simulatorToken: created.simulatorToken,
561
629
  claim: created.receipt.simulator.claim,
562
630
  artifact: created.receipt.artifact,
631
+ scopes: RNX_CLOUD_TOKEN_SCOPES,
563
632
  })
564
633
  await simulator.confirm()
565
634
  return simulator
566
635
  }
636
+
637
+ function readSimArtifact(value: unknown): RnxCloudCreateReceipt['artifact'] {
638
+ if (
639
+ !isRecord(value) ||
640
+ typeof value.sha256 !== 'string' ||
641
+ !/^[a-f0-9]{64}$/.test(value.sha256) ||
642
+ typeof value.bytes !== 'number' ||
643
+ !Number.isInteger(value.bytes) ||
644
+ value.bytes <= 0
645
+ ) {
646
+ throw new Error('RNX Cloud returned an invalid simulator artifact')
647
+ }
648
+ return { id: `sha256:${value.sha256}`, bytes: value.bytes }
649
+ }
650
+
651
+ function readConnectClaim(value: unknown): RnxCloudClaim | null {
652
+ if (value === null) return null
653
+ return readClaim(value)
654
+ }
655
+
656
+ function readConnectScopes(value: unknown, token: string): readonly RnxCloudCommandScope[] {
657
+ // the full per-simulator token carries every scope except debug, which the
658
+ // service already guarantees. a scoped token learns its verbs from the get
659
+ // it just made, so a verb outside them is refused before any request.
660
+ if (!token.startsWith(RNX_CLOUD_SCOPED_TOKEN_PREFIX)) return RNX_CLOUD_TOKEN_SCOPES
661
+ if (
662
+ !isRecord(value) ||
663
+ typeof value.id !== 'string' ||
664
+ !value.id ||
665
+ !Array.isArray(value.scopes) ||
666
+ value.scopes.length === 0 ||
667
+ !value.scopes.every(
668
+ (scope): scope is RnxCloudCommandScope =>
669
+ typeof scope === 'string' && isRnxCloudCommandScope(scope),
670
+ ) ||
671
+ typeof value.expiresAt !== 'number'
672
+ ) {
673
+ throw new Error('RNX Cloud returned an invalid scoped token')
674
+ }
675
+ return Object.freeze([...value.scopes])
676
+ }
677
+
678
+ // attach to a simulator another process created. one get proves the token,
679
+ // and the simulator it returns drives with that token's scopes, takes no
680
+ // claim, and never steals one.
681
+ export async function connect(input: ConnectRemoteSimulatorInput): Promise<RemoteSimulator> {
682
+ if (!input.id) throw new Error('RNX Cloud connect needs a simulator id')
683
+ if (!input.token) throw new Error('RNX Cloud connect needs a token')
684
+ const origin = resolveOrigin(input.origin)
685
+ let response: Response
686
+ try {
687
+ response = await fetchWithCloudRetry(
688
+ `${origin}/v1/sims/${encodeURIComponent(input.id)}`,
689
+ { headers: { authorization: `Bearer ${input.token}` } },
690
+ )
691
+ } catch (error) {
692
+ throw new Error(
693
+ `could not reach RNX Cloud at ${origin}: ${
694
+ error instanceof Error ? error.message : String(error)
695
+ }`,
696
+ )
697
+ }
698
+ const text = await response.text()
699
+ if (!response.ok) throw responseError(response.status, text)
700
+ let parsed: unknown
701
+ try {
702
+ parsed = JSON.parse(text)
703
+ } catch {
704
+ throw new Error('RNX Cloud connect returned unreadable JSON')
705
+ }
706
+ if (!isRecord(parsed) || !isRecord(parsed.sim)) {
707
+ throw new Error('RNX Cloud connect returned an invalid simulator')
708
+ }
709
+ const sim = parsed.sim
710
+ if (typeof sim.simId !== 'string' || !sim.simId) {
711
+ throw new Error('RNX Cloud connect returned an invalid simulator')
712
+ }
713
+ return new RemoteSimulator({
714
+ origin,
715
+ boxId: null,
716
+ boxToken: null,
717
+ simId: sim.simId,
718
+ simulatorToken: input.token,
719
+ claim: readConnectClaim(sim.claim ?? null),
720
+ artifact: readSimArtifact(sim.artifact),
721
+ scopes: readConnectScopes(isRecord(parsed) ? parsed.token : null, input.token),
722
+ })
723
+ }