@revyl/verify 0.1.0 → 0.3.0

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.
@@ -2,54 +2,69 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
- import { responseDetail, sessionViewerUrl } from './api/client.js';
5
+ import { edgeBlockedError, responseDetail, responseErrorCode, responseRefusalReason, sessionViewerUrl } from './api/client.js';
6
6
  import { trackSession, untrackSession } from './cleanup.js';
7
+ import { validateHostedTarget } from './config.js';
7
8
  import { VerifyError, isVerifyError } from './errors.js';
8
- import { pngSize } from './session.js';
9
+ import { DeviceSession, pngSize } from './session.js';
9
10
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
11
  const STATES = new Set(['starting', 'active', 'closing', 'stopped', 'failed', 'owner_lost']);
11
12
  const MAX_OUTPUT_BYTES = 1_048_576;
12
- const MAC_AVAILABILITY = 'Mac computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations. See https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md#availability-and-errors';
13
+ const COMPUTER_AVAILABILITY = 'Computers are early access. Ask support@revyl.ai about access.';
14
+ const COMPUTER_CAPACITY = 'Computer admission was refused because of queued work (including iOS) or the concurrency limit. Verify will not retry the start.';
13
15
  const ERROR_CODES = new Set(['computer_not_enabled', 'run_closed', 'inaccessible', 'invalid_handle', 'operation_not_found', 'owner_unavailable', 'invalid_request', 'unauthenticated', 'forbidden', 'runtime_unavailable', 'start_failed', 'computer_limit', 'request_conflict', 'run_conflict', 'evidence_conflict', 'evidence_unavailable', 'authentication_unavailable', 'operation_limit', 'screen_starting', 'screen_unavailable']);
14
16
  export class ComputerSession {
15
17
  client;
16
18
  options;
17
- platform = 'mac';
19
+ stopOnClose;
20
+ platform;
18
21
  target;
19
- id = randomUUID();
22
+ id;
20
23
  viewerUrl;
21
24
  steps = [];
22
25
  lifetime = new AbortController();
23
26
  closePromise;
24
- constructor(client, target, options) {
27
+ expiresAtMs;
28
+ screenReadyUntilMs = 0;
29
+ children = new Set();
30
+ childStarts = new Set();
31
+ closing = false;
32
+ constructor(client, target, options, id = randomUUID(), stopOnClose = true) {
25
33
  this.client = client;
26
34
  this.options = options;
35
+ this.stopOnClose = stopOnClose;
36
+ this.id = id;
27
37
  this.target = target.name;
28
- this.viewerUrl = sessionViewerUrl(this.id, 'computer');
38
+ this.platform = target.platform;
39
+ this.viewerUrl = sessionViewerUrl(this.id, 'computer', options.appUrl);
29
40
  }
30
41
  static async start(client, target, options) {
31
- if (target.platform !== 'mac')
32
- throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'Linux computers are planned, but are not available in Verify yet.');
33
42
  const session = new ComputerSession(client, target, options);
34
43
  const signal = AbortSignal.any([AbortSignal.timeout(target.startTimeout), session.lifetime.signal]);
44
+ let createAcknowledged = false;
35
45
  trackSession(session);
36
46
  try {
37
- session.log(`starting mac computer ${session.viewerUrl}. ${MAC_AVAILABILITY}`);
38
- let report = session.report(await session.request('POST', '', { computer_session_id: session.id, os: 'macos' }, signal));
47
+ session.log(`starting computer ${session.viewerUrl}. ${COMPUTER_AVAILABILITY}`);
48
+ const response = await session.request('POST', '', { computer_session_id: session.id, os: target.platform === 'mac' ? 'macos' : 'linux' }, signal);
49
+ createAcknowledged = true;
50
+ let report = session.parseReport(response);
39
51
  for (;;) {
40
52
  if (report.state === 'active')
41
53
  break;
42
- if (report.state !== 'starting')
43
- throw new VerifyError('SESSION_START_FAILED', `The ${session.target} computer ended before it was ready (${report.state}). See ${session.viewerUrl}`);
54
+ if (report.state !== 'starting') {
55
+ const reason = report.failure_reason === 'no_host' ? ' No host claimed the start within the 120-second unclaimed-start window.' : report.failure_reason === 'device_limit' ? ` ${COMPUTER_CAPACITY}` : '';
56
+ throw new VerifyError('SESSION_START_FAILED', `The ${session.target} computer ended before it was ready (${report.state}).${reason} See ${session.viewerUrl}`);
57
+ }
44
58
  await delay(2_000, undefined, { signal });
45
- report = session.report(await session.request('GET', '/report', undefined, signal));
59
+ report = session.parseReport(await session.request('GET', '/report', undefined, signal));
46
60
  }
61
+ session.screenReadyUntilMs = Date.now() + 30_000;
47
62
  session.log('ready');
48
63
  return session;
49
64
  }
50
65
  catch (error) {
51
66
  const timedOut = signal.aborted;
52
- if (isVerifyError(error) && ['AUTH_REJECTED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT'].includes(error.code)) {
67
+ if (!createAcknowledged && isVerifyError(error) && ['AUTH_REJECTED', 'EDGE_BLOCKED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT', 'COMPUTER_LIMIT'].includes(error.code)) {
53
68
  session.lifetime.abort();
54
69
  untrackSession(session);
55
70
  }
@@ -57,13 +72,84 @@ export class ComputerSession {
57
72
  await session.close().catch(() => undefined);
58
73
  }
59
74
  if (timedOut)
60
- throw new VerifyError('TIMEOUT', `The ${session.target} computer was not ready within ${target.startTimeout} ms. A possible cause is that no Mac computer could be assigned. ${MAC_AVAILABILITY}. Inspect ${session.viewerUrl}`);
75
+ throw new VerifyError('TIMEOUT', `The ${session.target} computer was not ready within ${target.startTimeout} ms. A possible cause is that no computer could be assigned. ${COMPUTER_AVAILABILITY} Inspect ${session.viewerUrl}`);
61
76
  throw error;
62
77
  }
63
78
  }
64
79
  get closed() {
65
80
  return this.closePromise !== undefined;
66
81
  }
82
+ static async attach(client, computerId, options) {
83
+ if (typeof computerId !== 'string' || !UUID.test(computerId))
84
+ throw new VerifyError('INVALID_ARGUMENT', 'attach() requires a computer session UUID.');
85
+ const probe = new ComputerSession(client, { name: 'computer', platform: 'mac', startTimeout: 600_000 }, options, computerId);
86
+ const response = await probe.request('GET', '/report', undefined, AbortSignal.timeout(30_000));
87
+ const raw = parseComputerJson(response);
88
+ if (!raw || raw.computer_session_id !== computerId || !['macos', 'linux'].includes(raw.os ?? '') || raw.expires_at == null)
89
+ throw invalidResponse();
90
+ const session = new ComputerSession(client, { name: raw.os === 'macos' ? 'mac' : 'linux', platform: raw.os === 'macos' ? 'mac' : 'linux', startTimeout: 600_000 }, options, computerId, options.stopOnClose ?? true);
91
+ session.parseReport(response);
92
+ if (raw.state !== 'active' || (session.expiresAtMs !== undefined && Date.now() >= session.expiresAtMs))
93
+ throw new VerifyError('SESSION_ENDED', 'The computer is not active or has expired.');
94
+ if (session.stopOnClose)
95
+ trackSession(session);
96
+ return session;
97
+ }
98
+ async open(target) {
99
+ this.assertOpen();
100
+ if (this.expiresAtMs === undefined)
101
+ throw new VerifyError('API_ERROR', 'The computer report must include expiry before opening a hosted child.');
102
+ const resolved = validateHostedTarget(target);
103
+ if ((this.platform === 'mac' && resolved.platform === 'android') || (this.platform === 'linux' && resolved.platform === 'ios'))
104
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'This computer cannot host the requested platform.', { reason: 'computer_cannot_host_platform' });
105
+ const pending = DeviceSession.start(this.client, resolved, this.options, {
106
+ id: this.id,
107
+ signal: this.lifetime.signal,
108
+ expiresAt: this.expiresAt,
109
+ register: (session) => { this.children.add(session); },
110
+ });
111
+ this.childStarts.add(pending);
112
+ try {
113
+ return await pending;
114
+ }
115
+ finally {
116
+ this.childStarts.delete(pending);
117
+ }
118
+ }
119
+ async report() {
120
+ const report = this.parseReport(await this.request('GET', '/report', undefined, AbortSignal.timeout(30_000)));
121
+ return { ...report };
122
+ }
123
+ navigate() {
124
+ return this.unavailable('navigate');
125
+ }
126
+ get expiresAt() {
127
+ return this.expiresAtMs === undefined ? undefined : new Date(this.expiresAtMs);
128
+ }
129
+ async recording() {
130
+ const signal = AbortSignal.timeout(30_000);
131
+ const report = this.parseReport(await this.request('GET', '/report', undefined, signal));
132
+ if (!['stopped', 'failed', 'owner_lost'].includes(report.state) || report.release !== 'confirmed') {
133
+ throw new VerifyError('SESSION_BUSY', `The computer recording can be requested only after terminal state and confirmed release. See ${this.viewerUrl}`);
134
+ }
135
+ const response = await this.request('GET', '/recording', undefined, signal);
136
+ if (response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
137
+ return null;
138
+ const raw = parseComputerJson(response);
139
+ if (!raw || typeof raw.url !== 'string' || !/^https:\/\//i.test(raw.url) || /[\x00-\x20\x7f]/.test(raw.url))
140
+ throw invalidResponse();
141
+ let url;
142
+ try {
143
+ url = new URL(raw.url);
144
+ }
145
+ catch {
146
+ throw invalidResponse();
147
+ }
148
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash || (raw.duration_seconds != null && (typeof raw.duration_seconds !== 'number' || !Number.isFinite(raw.duration_seconds) || raw.duration_seconds < 0)))
149
+ throw invalidResponse();
150
+ const startedAt = raw.started_at == null ? undefined : parseTimestamp(raw.started_at);
151
+ return { url: raw.url, ...(raw.duration_seconds != null ? { durationSeconds: raw.duration_seconds } : {}), ...(startedAt ? { startedAt } : {}) };
152
+ }
67
153
  instruction() {
68
154
  return this.unavailable('instruction');
69
155
  }
@@ -72,7 +158,38 @@ export class ComputerSession {
72
158
  }
73
159
  async screenshot(options = {}) {
74
160
  this.assertOpen();
75
- const response = await this.request('GET', '/screen/screenshot', undefined, this.lifetime.signal);
161
+ const signal = this.observationSignal(30_000);
162
+ let retrySignal = signal;
163
+ let response;
164
+ let transient = false;
165
+ let backoffMs = 500;
166
+ const notReady = () => new VerifyError('SESSION_BUSY', `The computer desktop is not ready for screenshots after its 30-second startup window. Try again later. See ${this.viewerUrl}`);
167
+ try {
168
+ for (;;) {
169
+ this.assertOpen();
170
+ response = await this.request('GET', '/screen/screenshot', undefined, retrySignal);
171
+ if (response.status >= 200 && response.status < 300)
172
+ break;
173
+ const remainingMs = this.screenReadyUntilMs - Date.now();
174
+ if (remainingMs <= 0)
175
+ throw notReady();
176
+ if (!transient)
177
+ retrySignal = AbortSignal.any([signal, AbortSignal.timeout(remainingMs)]);
178
+ transient = true;
179
+ await delay(Math.min(backoffMs, remainingMs), undefined, { signal: retrySignal });
180
+ if (Date.now() >= this.screenReadyUntilMs)
181
+ throw notReady();
182
+ backoffMs = Math.min(backoffMs * 2, 2_000);
183
+ }
184
+ }
185
+ catch (error) {
186
+ if (transient && !this.lifetime.signal.aborted && (this.expiresAtMs === undefined || Date.now() < this.expiresAtMs) && retrySignal.aborted && Date.now() >= this.screenReadyUntilMs)
187
+ throw notReady();
188
+ if (retrySignal.aborted)
189
+ throw new VerifyError('TIMEOUT', `Computer screenshot observation was cancelled or exceeded its deadline. See ${this.viewerUrl}`);
190
+ throw error;
191
+ }
192
+ this.assertOpen();
76
193
  const raw = parseComputerJson(response);
77
194
  const bytes = decodeBytes(raw?.png, 128 * MAX_OUTPUT_BYTES);
78
195
  const size = pngSize(bytes);
@@ -140,19 +257,35 @@ export class ComputerSession {
140
257
  }
141
258
  close() {
142
259
  this.closePromise ??= (async () => {
260
+ this.closing = true;
143
261
  this.lifetime.abort();
262
+ const childCleanup = (async () => {
263
+ await Promise.allSettled([...this.childStarts]);
264
+ return Promise.allSettled([...this.children].map((child) => child.close()));
265
+ })();
266
+ const childDeadline = new AbortController();
267
+ const childResults = await Promise.race([childCleanup, delay(5_000, undefined, { signal: childDeadline.signal })]);
268
+ childDeadline.abort();
144
269
  try {
145
- const report = this.report(await this.request('POST', '/stop', undefined, AbortSignal.timeout(310_000)));
270
+ if (!this.stopOnClose) {
271
+ if (!childResults || childResults.some((result) => result.status === 'rejected'))
272
+ throw new VerifyError('ACTION_UNCERTAIN', `Hosted child cleanup is unconfirmed; the attached computer was left running. See ${this.viewerUrl}`);
273
+ this.log('detached; computer left running');
274
+ return;
275
+ }
276
+ const report = this.parseReport(await this.request('POST', '/stop', undefined, AbortSignal.timeout(310_000)));
146
277
  if (report.release !== 'confirmed') {
147
278
  throw new VerifyError('ACTION_UNCERTAIN', `Computer stop was requested, but physical release is unconfirmed. Inspect ${this.viewerUrl}`);
148
279
  }
149
280
  if (!['stopped', 'failed', 'owner_lost'].includes(report.state))
150
281
  throw invalidResponse();
282
+ if (!childResults || childResults.some((result) => result.status === 'rejected'))
283
+ this.log('a child stop was unconfirmed before the parent was released');
151
284
  this.log('closed');
152
285
  }
153
286
  catch (error) {
154
- if (isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
155
- const message = `${error.message} A possible cause is that no Mac computer could be assigned. ${MAC_AVAILABILITY}`;
287
+ if (this.stopOnClose && isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
288
+ const message = `${error.message} A possible cause is that no computer could be assigned. ${COMPUTER_AVAILABILITY}`;
156
289
  this.log(`close failed (ACTION_UNCERTAIN); ${message}`);
157
290
  throw new VerifyError('ACTION_UNCERTAIN', message, { cause: error });
158
291
  }
@@ -172,10 +305,21 @@ export class ComputerSession {
172
305
  this.assertOpen();
173
306
  const requestId = randomUUID();
174
307
  const operationId = computerOperationId(this.id, requestId);
175
- const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(310_000)]);
308
+ const signal = this.observationSignal(310_000);
309
+ let wireCommand = command;
310
+ if (command.kind === 'exec') {
311
+ const { argv, ...fields } = command;
312
+ wireCommand = { ...fields, argv_b64: argv.map(encodeWireText) };
313
+ }
314
+ else if (command.kind === 'file_read' || command.kind === 'file_write' || command.kind === 'file_stat') {
315
+ const { path, ...fields } = command;
316
+ wireCommand = { ...fields, path_b64: encodeWireText(path) };
317
+ }
176
318
  try {
177
- let response = await this.request('POST', '/operations', { computer_session_id: this.id, request_id: requestId, command }, signal);
319
+ let response = await this.request('POST', '/operations', { computer_session_id: this.id, request_id: requestId, command: wireCommand }, signal);
178
320
  for (;;) {
321
+ if (signal.aborted)
322
+ throw signal.reason;
179
323
  if (response.status !== 202) {
180
324
  const body = parseComputerJson(response);
181
325
  if (body?.computer_session_id !== this.id || body.operation_id !== operationId || !['recorded', 'pending', 'failed'].includes(body.evidence) || !body.execution)
@@ -224,7 +368,7 @@ export class ComputerSession {
224
368
  const path = `/api/v1/computer-runs${suffix === '' ? '' : `/${this.id}${suffix}`}`;
225
369
  let response;
226
370
  try {
227
- response = await this.client.send(method, path, { body, signal, timeoutMs: suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000, retry: method === 'GET', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
371
+ response = await this.client.send(method, path, { body, signal, timeoutMs: suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000, retry: method === 'GET' && suffix !== '/screen/screenshot', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
228
372
  }
229
373
  catch (error) {
230
374
  if (method === 'POST' || suffix.startsWith('/operations/'))
@@ -235,12 +379,32 @@ export class ComputerSession {
235
379
  }
236
380
  if (response.status >= 200 && response.status < 300)
237
381
  return response;
238
- const detail = responseDetail(response);
239
- if (detail === 'computer_not_enabled' || (method === 'POST' && suffix === '' && response.status === 503 && detail === 'runtime_unavailable')) {
240
- throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Revyl refused the computer request (${detail}). ${MAC_AVAILABILITY}. Ask support@revyl.ai about staging organization enablement. See ${this.viewerUrl}`);
382
+ const blocked = edgeBlockedError(response);
383
+ if (blocked)
384
+ throw blocked;
385
+ if (suffix === '/recording' && response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
386
+ return response;
387
+ const detail = responseRefusalReason(response) ?? responseDetail(response);
388
+ const refusal = { reason: responseRefusalReason(response), httpStatus: response.status };
389
+ if (suffix === '/screen/screenshot') {
390
+ let raw = null;
391
+ try {
392
+ raw = JSON.parse(new TextDecoder().decode(response.body));
393
+ }
394
+ catch { }
395
+ const code = raw?.detail ?? raw?.message ?? raw?.error;
396
+ if ((code === 'screen_unavailable' && (response.status === 409 || response.status === 503)) || (code === 'screen_starting' && response.status === 409))
397
+ return response;
241
398
  }
399
+ if (detail === 'computer_not_enabled' || (response.status === 503 && detail === 'runtime_unavailable')) {
400
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Revyl refused the computer request (${detail}). ${COMPUTER_AVAILABILITY} See ${this.viewerUrl}`, refusal);
401
+ }
402
+ if (response.status === 429 && detail === 'computer_limit')
403
+ throw new VerifyError('COMPUTER_LIMIT', `${COMPUTER_CAPACITY} See ${this.viewerUrl}`, refusal);
242
404
  let code = 'API_ERROR';
243
- if (detail === 'run_closed')
405
+ if (response.status === 401 || response.status === 403)
406
+ code = 'AUTH_REJECTED';
407
+ else if (detail === 'run_closed')
244
408
  code = 'SESSION_ENDED';
245
409
  else if (detail === 'inaccessible' || detail === 'invalid_handle')
246
410
  code = 'NOT_FOUND';
@@ -248,9 +412,7 @@ export class ComputerSession {
248
412
  code = 'ACTION_UNCERTAIN';
249
413
  else if (detail === 'invalid_request' || response.status === 422)
250
414
  code = 'INVALID_ARGUMENT';
251
- else if (response.status === 401 || response.status === 403)
252
- code = 'AUTH_REJECTED';
253
- else if (detail === 'runtime_unavailable' || detail === 'start_failed' || detail === 'computer_limit')
415
+ else if (detail === 'start_failed')
254
416
  code = 'SESSION_START_FAILED';
255
417
  else if (response.status === 409 || detail === 'operation_limit')
256
418
  code = 'SESSION_BUSY';
@@ -259,20 +421,26 @@ export class ComputerSession {
259
421
  else if (method === 'POST' && (response.status >= 500 || response.status < 400))
260
422
  code = 'ACTION_UNCERTAIN';
261
423
  const reason = detail && ERROR_CODES.has(detail) ? detail : `HTTP ${response.status}`;
262
- throw new VerifyError(code, `Revyl refused the computer request (${reason}). See ${this.viewerUrl}`);
424
+ throw new VerifyError(code, `Revyl refused the computer request (${reason}). See ${this.viewerUrl}`, refusal);
263
425
  }
264
- report(response) {
426
+ parseReport(response) {
265
427
  const report = parseComputerJson(response);
266
428
  if (!report || report.computer_session_id !== this.id || !STATES.has(report.state))
267
429
  throw invalidResponse();
430
+ if (report.expires_at != null)
431
+ this.expiresAtMs = parseTimestamp(report.expires_at).getTime();
268
432
  return report;
269
433
  }
270
434
  assertOpen() {
271
- if (this.closed)
272
- throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed.`);
435
+ if (this.closing || this.closed || (this.expiresAtMs !== undefined && Date.now() >= this.expiresAtMs))
436
+ throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed or expired.`);
437
+ }
438
+ observationSignal(timeoutMs) {
439
+ const remainingMs = this.expiresAtMs === undefined ? timeoutMs : Math.min(timeoutMs, this.expiresAtMs - Date.now());
440
+ return AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(Math.max(0, remainingMs))]);
273
441
  }
274
442
  unavailable(method) {
275
- return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on mac computers; use exec() and file operations. See ${this.viewerUrl}`));
443
+ return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on computers; use exec() and file operations. See ${this.viewerUrl}`));
276
444
  }
277
445
  log(line) {
278
446
  this.options.log?.(`${this.target}: ${line}`);
@@ -291,6 +459,9 @@ function operationTimeout(options) {
291
459
  throw new VerifyError('INVALID_ARGUMENT', 'Computer operation timeout must be an integer from 1 to 300000 ms.');
292
460
  return timeout;
293
461
  }
462
+ function encodeWireText(value) {
463
+ return Buffer.from(value, 'utf8').toString('base64').replaceAll('+', '-').replaceAll('/', '_');
464
+ }
294
465
  function guestPath(path) {
295
466
  if (typeof path !== 'string' || !path || Buffer.byteLength(path) > 1024 || path.split('/').some((part) => ['', '.', '..'].includes(part)) || /[\x00-\x1f\x7f\\:]/.test(path))
296
467
  throw new VerifyError('INVALID_ARGUMENT', 'Computer paths must be workspace-relative, without empty, dot, parent, backslash, colon, or control-character components.');
@@ -312,6 +483,14 @@ function decodeBytes(value, maxBytes) {
312
483
  function invalidResponse() {
313
484
  return new VerifyError('API_ERROR', 'Revyl returned an invalid computer response.');
314
485
  }
486
+ function parseTimestamp(value) {
487
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))
488
+ throw invalidResponse();
489
+ const calendarTime = new Date(`${value.slice(0, 19)}Z`);
490
+ if (!Number.isFinite(calendarTime.getTime()) || calendarTime.toISOString().slice(0, 19) !== value.slice(0, 19))
491
+ throw invalidResponse();
492
+ return new Date(value);
493
+ }
315
494
  function parseComputerJson(response) {
316
495
  try {
317
496
  return JSON.parse(new TextDecoder().decode(response.body));
package/dist/config.d.ts CHANGED
@@ -46,12 +46,18 @@ export interface MobileTargetConfig {
46
46
  startTimeout?: number;
47
47
  idleTimeoutSeconds?: number;
48
48
  }
49
- /** A Revyl computer: a cloud macOS machine that runs commands and files instead of agent steps. See docs/computers.md. */
49
+ /** A Revyl computer that runs commands and files instead of agent steps. See docs/computers.md. */
50
50
  export interface ComputerTargetConfig {
51
51
  platform: ComputerPlatform;
52
52
  startTimeout?: number;
53
53
  }
54
54
  export type TargetConfig = WebTargetConfig | MobileTargetConfig | ComputerTargetConfig;
55
+ export type HostedTargetConfig = (Omit<WebTargetConfig, 'app'> | Omit<MobileTargetConfig, 'app' | 'device'>) & {
56
+ app: {
57
+ appId: string;
58
+ buildId: string;
59
+ };
60
+ };
55
61
  export interface VerifyConfig {
56
62
  /** Named targets that open() takes by name, such as open('web'). */
57
63
  targets: Record<string, TargetConfig>;
@@ -85,4 +91,5 @@ export declare function loadConfig(cwd: string, explicitPath?: string): Promise<
85
91
  export declare function validateConfig(raw: unknown, configFile: string): ResolvedConfig;
86
92
  /** Validates a target passed straight to open(); it is named after its platform. */
87
93
  export declare function validateTarget(raw: unknown): ResolvedTarget;
94
+ export declare function validateHostedTarget(raw: HostedTargetConfig): ResolvedDeviceTarget;
88
95
  export {};
package/dist/config.js CHANGED
@@ -71,7 +71,14 @@ export function validateTarget(raw) {
71
71
  const platform = asRecord(raw, 'target', fail).platform;
72
72
  return parseTarget(raw, typeof platform === 'string' ? platform : 'target', 'target', fail);
73
73
  }
74
- function parseTarget(raw, name, path, fail) {
74
+ export function validateHostedTarget(raw) {
75
+ const fail = (path, problem) => { throw new VerifyError('INVALID_ARGUMENT', `computer.open(): ${path} ${problem}`); };
76
+ const target = parseTarget(raw, raw?.platform ?? 'target', 'target', fail, true);
77
+ if (target.platform === 'mac' || target.platform === 'linux')
78
+ fail('target.platform', 'must be web, ios, or android.');
79
+ return target;
80
+ }
81
+ function parseTarget(raw, name, path, fail, hosted = false) {
75
82
  const target = asRecord(raw, path, fail);
76
83
  const platform = target.platform;
77
84
  if (typeof platform !== 'string' || !PLATFORMS.includes(platform)) {
@@ -88,6 +95,13 @@ function parseTarget(raw, name, path, fail) {
88
95
  idleTimeoutSeconds: integer(target.idleTimeoutSeconds, `${path}.idleTimeoutSeconds`, 60, 3_600, 600, fail),
89
96
  };
90
97
  const app = asRecord(target.app, `${path}.app`, fail);
98
+ if (hosted) {
99
+ uuid(app.appId, `${path}.app.appId`, true, fail);
100
+ uuid(app.buildId, `${path}.app.buildId`, true, fail);
101
+ rejectUnknownKeys(app, ['appId', 'buildId'], `${path}.app`, fail);
102
+ if (target.device !== undefined)
103
+ fail(`${path}.device`, 'is fixed by the hosting computer.');
104
+ }
91
105
  if (platform === 'web') {
92
106
  rejectUnknownKeys(target, ['platform', 'app', 'viewport', 'signIn', 'startTimeout', 'idleTimeoutSeconds'], path, fail);
93
107
  rejectUnknownKeys(app, ['appId', 'url', 'buildId', 'branch'], `${path}.app`, fail);
@@ -153,7 +167,7 @@ function parseTarget(raw, name, path, fail) {
153
167
  fail(`${path}.app.artifactUrl`, 'must be an HTTPS URL.');
154
168
  }
155
169
  const sources = [app.appId, app.buildId, app.artifactUrl, app.bundleId].filter((value) => value !== undefined).length;
156
- if (sources !== 1) {
170
+ if (!hosted && sources !== 1) {
157
171
  fail(`${path}.app`, 'takes exactly one of appId, buildId, or artifactUrl to install an app, or bundleId to open one already on the device.');
158
172
  }
159
173
  let device;
package/dist/errors.d.ts CHANGED
@@ -1,8 +1,13 @@
1
- export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'AUTH_REJECTED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | 'NOT_AVAILABLE_ON_PLATFORM' | 'ACTION_FAILED' | 'ACTION_UNCERTAIN' | 'INSTRUCTION_FAILED' | 'VALIDATION_FAILED' | 'STEP_FAILED' | 'COMMAND_FAILED' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'TIMEOUT';
1
+ export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'COMPUTER_ID_MISSING' | 'AUTH_REJECTED' | 'EDGE_BLOCKED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | 'COMPUTER_LIMIT' | 'NOT_AVAILABLE_ON_PLATFORM' | 'ACTION_FAILED' | 'ACTION_UNCERTAIN' | 'INSTRUCTION_FAILED' | 'VALIDATION_FAILED' | 'STEP_FAILED' | 'COMMAND_FAILED' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'TIMEOUT';
2
+ export type RefusalReason = 'web_runtime_unavailable' | 'web_runtime_not_configured' | 'computer_limit' | 'runtime_unavailable' | 'computer_runtime_unavailable' | 'computer_at_capacity' | 'computer_not_running' | 'computer_cannot_host_platform' | 'computer_not_found' | 'web_build_required' | 'web_build_not_supported' | 'ios_build_required' | 'ios_build_not_supported' | 'android_build_required' | 'android_build_not_supported' | 'web_session_conflict' | 'ios_session_conflict' | 'android_session_conflict' | 'not_available_on_platform' | 'request_timeout' | 'protocol_error';
2
3
  export declare class VerifyError extends Error {
3
4
  readonly code: VerifyErrorCode;
5
+ readonly reason?: RefusalReason;
6
+ readonly httpStatus?: number;
4
7
  constructor(code: VerifyErrorCode, message: string, options?: {
5
8
  cause?: unknown;
9
+ reason?: RefusalReason;
10
+ httpStatus?: number;
6
11
  });
7
12
  }
8
13
  export declare function isVerifyError(error: unknown): error is VerifyError;
package/dist/errors.js CHANGED
@@ -1,9 +1,13 @@
1
1
  export class VerifyError extends Error {
2
2
  code;
3
+ reason;
4
+ httpStatus;
3
5
  constructor(code, message, options) {
4
6
  super(message, options);
5
7
  this.name = 'VerifyError';
6
8
  this.code = code;
9
+ this.reason = options?.reason;
10
+ this.httpStatus = options?.httpStatus;
7
11
  }
8
12
  }
9
13
  export function isVerifyError(error) {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- export { open } from './open.js';
2
- export type { OpenOptions } from './open.js';
3
- export type { Session, StepOptions, StepResult, StepType, ValidationOptions, OperationOptions, ExecOptions, ExecResult, FileStat, ComputerProcess } from './session.js';
1
+ export { attach, currentComputer, open } from './open.js';
2
+ export type { AttachOptions, OpenOptions } from './open.js';
3
+ export type { OpenedSession, Session, StepOptions, StepResult, StepType, ValidationOptions, OperationOptions, ExecOptions, ExecResult, FileStat, ComputerProcess, ComputerRecording } from './session.js';
4
4
  export { defineConfig } from './config.js';
5
- export type { MobileAppConfig, MobileTargetConfig, ComputerTargetConfig, TargetConfig, VerifyConfig, WebAppConfig, WebTargetConfig } from './config.js';
5
+ export type { HostedTargetConfig, MobileAppConfig, MobileTargetConfig, ComputerTargetConfig, TargetConfig, VerifyConfig, WebAppConfig, WebTargetConfig } from './config.js';
6
6
  export type { Platform, ComputerPlatform, MobilePlatform } from './api/types.js';
7
7
  export { VerifyError, isVerifyError } from './errors.js';
8
- export type { VerifyErrorCode } from './errors.js';
8
+ export type { RefusalReason, VerifyErrorCode } from './errors.js';
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { open } from './open.js';
1
+ export { attach, currentComputer, open } from './open.js';
2
2
  export { defineConfig } from './config.js';
3
3
  export { VerifyError, isVerifyError } from './errors.js';
package/dist/open.d.ts CHANGED
@@ -1,13 +1,21 @@
1
1
  import { type TargetConfig } from './config.js';
2
- import { type Session } from './session.js';
2
+ import { type OpenedSession } from './session.js';
3
+ import { ComputerSession } from './computer-session.js';
3
4
  export interface OpenOptions {
4
5
  /** The config file to read target names from. Defaults to the nearest verify.config.ts at or above the current folder. */
5
6
  config?: string;
6
7
  /** Where progress lines go: the session link and each step's outcome. Defaults to stderr; false turns them off. */
7
8
  log?: false | ((line: string) => void);
9
+ backendUrl?: string;
10
+ appUrl?: string;
8
11
  }
12
+ export interface AttachOptions extends Pick<OpenOptions, 'log' | 'backendUrl' | 'appUrl'> {
13
+ stopOnClose?: boolean;
14
+ }
15
+ export declare function attach(computerId: string, options?: AttachOptions): Promise<ComputerSession>;
16
+ export declare function currentComputer(options?: AttachOptions): Promise<ComputerSession>;
9
17
  /**
10
- * Starts a Revyl cloud browser, device, or Mac computer and resolves once it is ready.
18
+ * Starts a Revyl cloud browser, device, or computer. Mobile first-screen readiness is best-effort.
11
19
  * Pass a target name from verify.config.ts, such as open('web'), or a target object.
12
20
  */
13
- export declare function open(target: string | TargetConfig, options?: OpenOptions): Promise<Session>;
21
+ export declare function open(target: string | TargetConfig, options?: OpenOptions): Promise<OpenedSession>;
package/dist/open.js CHANGED
@@ -1,11 +1,42 @@
1
1
  import { RevylClient } from './api/client.js';
2
2
  import { missingCredentialsMessage, resolveCredentials } from './auth.js';
3
+ import { signIn } from './cli/login.js';
3
4
  import { DEFAULT_STEP_TIMEOUT_MS, loadConfig, validateTarget } from './config.js';
4
5
  import { VerifyError } from './errors.js';
5
6
  import { DeviceSession } from './session.js';
6
7
  import { ComputerSession } from './computer-session.js';
8
+ const signIns = new Map();
9
+ async function credentialsForUse(options) {
10
+ const credentials = await resolveCredentials();
11
+ if (credentials)
12
+ return credentials;
13
+ if (!process.stdin.isTTY || !process.stderr.isTTY || process.env.CI)
14
+ throw new VerifyError('AUTH_MISSING', missingCredentialsMessage());
15
+ const key = JSON.stringify([options.backendUrl, options.appUrl]);
16
+ let pending = signIns.get(key);
17
+ if (!pending) {
18
+ pending = signIn(options).finally(() => signIns.delete(key));
19
+ signIns.set(key, pending);
20
+ }
21
+ return pending;
22
+ }
23
+ export async function attach(computerId, options = {}) {
24
+ if (typeof computerId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(computerId))
25
+ throw new VerifyError('INVALID_ARGUMENT', 'attach() requires a computer session UUID.');
26
+ if (options.stopOnClose !== undefined && typeof options.stopOnClose !== 'boolean')
27
+ throw new VerifyError('INVALID_ARGUMENT', 'stopOnClose must be a boolean.');
28
+ const credentials = await credentialsForUse(options);
29
+ const log = options.log === false ? undefined : (options.log ?? ((line) => void process.stderr.write(`[verify] ${line}\n`)));
30
+ return ComputerSession.attach(new RevylClient({ token: credentials.token, backendUrl: options.backendUrl }), computerId, { stepTimeout: DEFAULT_STEP_TIMEOUT_MS, log, appUrl: options.appUrl, stopOnClose: options.stopOnClose });
31
+ }
32
+ export async function currentComputer(options = {}) {
33
+ const computerId = process.env.REVYL_COMPUTER_ID?.trim();
34
+ if (!computerId)
35
+ throw new VerifyError('COMPUTER_ID_MISSING', 'No current Revyl computer is available. Run this job on a revyl-macos runner.');
36
+ return attach(computerId, { ...options, stopOnClose: options.stopOnClose === undefined ? false : options.stopOnClose });
37
+ }
7
38
  /**
8
- * Starts a Revyl cloud browser, device, or Mac computer and resolves once it is ready.
39
+ * Starts a Revyl cloud browser, device, or computer. Mobile first-screen readiness is best-effort.
9
40
  * Pass a target name from verify.config.ts, such as open('web'), or a target object.
10
41
  */
11
42
  export async function open(target, options = {}) {
@@ -22,15 +53,10 @@ export async function open(target, options = {}) {
22
53
  else {
23
54
  resolved = validateTarget(target);
24
55
  }
25
- if (resolved.platform === 'linux') {
26
- throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'Linux computers are planned, but are not available in Verify yet. See docs/computers.md.');
27
- }
28
- const credentials = await resolveCredentials();
29
- if (!credentials)
30
- throw new VerifyError('AUTH_MISSING', missingCredentialsMessage());
56
+ const credentials = await credentialsForUse(options);
31
57
  const log = options.log === false ? undefined : (options.log ?? ((line) => void process.stderr.write(`[verify] ${line}\n`)));
32
- const client = new RevylClient({ token: credentials.token });
33
- if (resolved.platform === 'mac')
34
- return ComputerSession.start(client, resolved, { stepTimeout, log });
35
- return DeviceSession.start(client, resolved, { stepTimeout, log });
58
+ const client = new RevylClient({ token: credentials.token, backendUrl: options.backendUrl });
59
+ if (resolved.platform === 'mac' || resolved.platform === 'linux')
60
+ return ComputerSession.start(client, resolved, { stepTimeout, log, appUrl: options.appUrl });
61
+ return DeviceSession.start(client, resolved, { stepTimeout, log, appUrl: options.appUrl });
36
62
  }
@@ -0,0 +1 @@
1
+ export declare function screenHasContent(bytes: Uint8Array): boolean | undefined;
@@ -0,0 +1,27 @@
1
+ import { PNG } from 'pngjs';
2
+ export function screenHasContent(bytes) {
3
+ if (bytes.length < 24 || bytes.length > 16 * 1024 * 1024)
4
+ return undefined;
5
+ const header = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
6
+ const width = header.readUInt32BE(16);
7
+ const height = header.readUInt32BE(20);
8
+ if (width === 0 || height === 0 || width * height > 16_000_000)
9
+ return undefined;
10
+ try {
11
+ const { data } = PNG.sync.read(header);
12
+ const left = Math.floor(width / 10);
13
+ const top = Math.floor(height / 10);
14
+ const first = (top * width + left) * 4;
15
+ for (let y = top; y < Math.ceil(height * 0.9); y += 1) {
16
+ for (let x = left; x < Math.ceil(width * 0.9); x += 1) {
17
+ const offset = (y * width + x) * 4;
18
+ if (data[offset] !== data[first] || data[offset + 1] !== data[first + 1] || data[offset + 2] !== data[first + 2])
19
+ return true;
20
+ }
21
+ }
22
+ return false;
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }