@revyl/verify 0.2.0 → 0.3.1

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.
@@ -0,0 +1,200 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
4
+ import { homedir, hostname } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { appUrl, RevylClient } from '../api/client.js';
8
+ import { missingCredentialsMessage, resolveCredentials } from '../auth.js';
9
+ import { isVerifyError, VerifyError } from '../errors.js';
10
+ const AUTH_PATH = '/api/v1/entity/users/cli-device-authorizations';
11
+ export async function signIn(options = {}) {
12
+ const existing = options.force ? undefined : await resolveCredentials();
13
+ if (existing)
14
+ return existing;
15
+ if (!process.stdin.isTTY || !process.stderr.isTTY || process.env.CI)
16
+ throw new VerifyError('AUTH_MISSING', `${missingCredentialsMessage()} Set the REVYL_API_KEY secret for non-interactive runs.`);
17
+ const backendUrl = new RevylClient({ token: '', backendUrl: options.backendUrl }).backendUrl;
18
+ const viewerOrigin = new URL(appUrl(options.appUrl)).origin;
19
+ const controller = new AbortController();
20
+ const cancel = () => controller.abort();
21
+ process.once('SIGINT', cancel);
22
+ process.once('SIGTERM', cancel);
23
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(600_000)]);
24
+ try {
25
+ const authorization = await post(backendUrl, AUTH_PATH, { client_instance_id: await clientInstanceId(), device_label: hostname().trim().replace(/\s+/g, ' ').slice(0, 80) || 'Revyl CLI' }, signal);
26
+ const deviceCode = requiredString(authorization, 'device_code');
27
+ const userCode = requiredString(authorization, 'user_code');
28
+ const rawUrl = requiredString(authorization, 'verification_uri_complete');
29
+ let url;
30
+ try {
31
+ url = new URL(rawUrl);
32
+ }
33
+ catch {
34
+ throw invalidResponse();
35
+ }
36
+ if (url.origin !== viewerOrigin || url.pathname !== '/cli/device' || url.username || url.password)
37
+ throw invalidResponse();
38
+ const expiresIn = authorization.expires_in;
39
+ const firstInterval = authorization.interval;
40
+ if (!Number.isInteger(expiresIn) || typeof expiresIn !== 'number' || expiresIn <= 0 || !Number.isInteger(firstInterval) || typeof firstInterval !== 'number' || firstInterval < 0)
41
+ throw invalidResponse();
42
+ const deadline = Date.now() + Math.min(expiresIn * 1000, 600_000);
43
+ let intervalMs = firstInterval > 0 ? firstInterval * 1000 : 5000;
44
+ process.stderr.write(`Open this URL to approve, then confirm the code below:\n${url.href}\nCode: ${userCode}\nWaiting for approval (Ctrl+C to cancel)...\n`);
45
+ openBrowser(url.href);
46
+ while (Date.now() < deadline) {
47
+ await delay(Math.min(intervalMs, deadline - Date.now()), undefined, { signal });
48
+ if (Date.now() >= deadline)
49
+ break;
50
+ const credential = await post(backendUrl, `${AUTH_PATH}/credentials`, { device_code: deviceCode }, signal);
51
+ if (credential.state === 'pending') {
52
+ intervalMs = Math.min(intervalMs * 1.5, 15_000);
53
+ continue;
54
+ }
55
+ if (credential.state === 'denied')
56
+ throw new VerifyError('AUTH_REJECTED', 'The Revyl login request was denied. Run your script again to sign in, or use revyl auth login.');
57
+ if (credential.state !== 'approved')
58
+ throw invalidResponse();
59
+ const token = requiredString(credential, 'api_key_token');
60
+ const who = await new RevylClient({ token, backendUrl }).whoAmI();
61
+ const concurrent = await resolveCredentials();
62
+ if (concurrent) {
63
+ try {
64
+ await new RevylClient({ token: concurrent.token, backendUrl }).whoAmI();
65
+ return concurrent;
66
+ }
67
+ catch (error) {
68
+ if (!isVerifyError(error) || error.code !== 'AUTH_REJECTED')
69
+ throw error;
70
+ }
71
+ }
72
+ const keyId = optionalString(credential, 'api_key_id');
73
+ const stored = {
74
+ ...(keyId ? { api_key: token, api_key_id: keyId, auth_method: 'browser_api_key' } : { access_token: token, expires_at: tokenExpiry(token), auth_method: 'browser' }),
75
+ ...(who.email || optionalString(credential, 'email') ? { email: who.email || optionalString(credential, 'email') } : {}),
76
+ ...(who.org_id || optionalString(credential, 'org_id') ? { org_id: who.org_id || optionalString(credential, 'org_id') } : {}),
77
+ ...(who.user_id || optionalString(credential, 'user_id') ? { user_id: who.user_id || optionalString(credential, 'user_id') } : {}),
78
+ ...(process.env.REVYL_API_KEY?.trim() ? { local_auth_override: true } : {}),
79
+ };
80
+ await writePrivateState('credentials.json', stored);
81
+ return { token, source: 'revyl-cli-login' };
82
+ }
83
+ throw new VerifyError('AUTH_MISSING', 'The Revyl login request expired. Run your script again to sign in, or use revyl auth login.');
84
+ }
85
+ catch (error) {
86
+ if (signal.aborted)
87
+ throw new VerifyError('AUTH_MISSING', 'Revyl sign-in was cancelled or timed out. Run your script again to sign in, or use revyl auth login.');
88
+ throw error;
89
+ }
90
+ finally {
91
+ process.removeListener('SIGINT', cancel);
92
+ process.removeListener('SIGTERM', cancel);
93
+ }
94
+ }
95
+ async function post(backendUrl, path, body, signal) {
96
+ let response;
97
+ try {
98
+ response = await fetch(new URL(path, backendUrl), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]) });
99
+ }
100
+ catch {
101
+ throw new VerifyError('AUTH_MISSING', 'Could not reach Revyl to sign in. Run your script again, use revyl auth login, or set REVYL_API_KEY.');
102
+ }
103
+ if (response.status === 404)
104
+ throw new VerifyError('AUTH_MISSING', 'The Revyl login request is unavailable or expired. Run your script again, or use revyl auth login.');
105
+ if (!response.ok)
106
+ throw new VerifyError('AUTH_REJECTED', `Revyl sign-in failed (HTTP ${response.status}). Run your script again, use revyl auth login, or set REVYL_API_KEY.`);
107
+ let value;
108
+ try {
109
+ value = await response.json();
110
+ }
111
+ catch {
112
+ throw invalidResponse();
113
+ }
114
+ if (!value || typeof value !== 'object' || Array.isArray(value))
115
+ throw invalidResponse();
116
+ return value;
117
+ }
118
+ function requiredString(value, key) {
119
+ const text = optionalString(value, key);
120
+ if (!text)
121
+ throw invalidResponse();
122
+ return text;
123
+ }
124
+ function optionalString(value, key) {
125
+ const text = value[key];
126
+ if (text === null || text === undefined)
127
+ return undefined;
128
+ if (typeof text !== 'string' || text.length > 8192 || /[\x00-\x1f\x7f]/.test(text))
129
+ throw invalidResponse();
130
+ return text || undefined;
131
+ }
132
+ function invalidResponse() {
133
+ return new VerifyError('AUTH_REJECTED', 'Revyl returned an invalid sign-in response. Run your script again, use revyl auth login, or set REVYL_API_KEY.');
134
+ }
135
+ function tokenExpiry(token) {
136
+ try {
137
+ const payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8'));
138
+ if (typeof payload.exp === 'number' && Number.isFinite(payload.exp) && payload.exp > 0)
139
+ return new Date(Math.trunc(payload.exp) * 1000).toISOString();
140
+ }
141
+ catch { }
142
+ return new Date(Date.now() + 30 * 60_000).toISOString();
143
+ }
144
+ async function clientInstanceId() {
145
+ const path = join(homedir(), '.revyl', 'client-identity.json');
146
+ try {
147
+ const identity = JSON.parse(await readFile(path, 'utf8'));
148
+ if (identity && typeof identity === 'object' && 'client_instance_id' in identity && typeof identity.client_instance_id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(identity.client_instance_id))
149
+ return identity.client_instance_id;
150
+ }
151
+ catch (error) {
152
+ if (!(error instanceof SyntaxError) && error.code !== 'ENOENT')
153
+ throw new VerifyError('AUTH_MISSING', 'Could not read the Revyl client identity. Check ~/.revyl permissions.');
154
+ }
155
+ const id = randomBytes(16).toString('hex');
156
+ await writePrivateState('client-identity.json', { client_instance_id: id });
157
+ return id;
158
+ }
159
+ async function writePrivateState(name, value) {
160
+ const directory = join(homedir(), '.revyl');
161
+ const destination = join(directory, name);
162
+ const temporary = join(directory, `.${name}.${randomBytes(16).toString('hex')}`);
163
+ try {
164
+ await mkdir(directory, { recursive: true, mode: 0o700 });
165
+ if (!(await lstat(directory)).isDirectory() || (await lstat(directory)).isSymbolicLink())
166
+ throw new Error();
167
+ if (process.platform !== 'win32')
168
+ await chmod(directory, 0o700);
169
+ try {
170
+ if (!(await lstat(destination)).isFile())
171
+ throw new Error();
172
+ }
173
+ catch (error) {
174
+ if (error.code !== 'ENOENT')
175
+ throw error;
176
+ }
177
+ const file = await open(temporary, 'wx', 0o600);
178
+ try {
179
+ await file.writeFile(`${JSON.stringify(value, null, 2)}\n`);
180
+ await file.sync();
181
+ }
182
+ finally {
183
+ await file.close();
184
+ }
185
+ await rename(temporary, destination);
186
+ }
187
+ catch {
188
+ throw new VerifyError('AUTH_MISSING', 'Could not save Revyl sign-in state. Use a writable, owner-only ~/.revyl directory.');
189
+ }
190
+ finally {
191
+ await rm(temporary, { force: true });
192
+ }
193
+ }
194
+ function openBrowser(url) {
195
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
196
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
197
+ const child = spawn(command, args, { stdio: 'ignore', timeout: 10_000 });
198
+ child.once('error', () => process.stderr.write('Open the URL above in your browser to finish signing in.\n'));
199
+ child.unref();
200
+ }
package/dist/cli/main.js CHANGED
@@ -6,11 +6,13 @@ import { missingCredentialsMessage, resolveCredentials } from '../auth.js';
6
6
  import { VerifyError, errorMessage, isVerifyError } from '../errors.js';
7
7
  import { PACKAGE_NAME, VERSION } from '../version.js';
8
8
  import { initProject, validateInitAnswers } from './init.js';
9
+ import { installVerify, packageHasVerify } from './install.js';
10
+ import { signIn } from './login.js';
9
11
  const HELP = `verify ${VERSION}: open Revyl cloud browsers, devices, and computers from your code
10
12
 
11
13
  Usage
12
- npx ${PACKAGE_NAME} init [--platform web|ios|android|mac] [--app-id <id>] [--url <https-url>] [--force]
13
- Write verify.config.ts and an example script, verify/example.ts
14
+ npx ${PACKAGE_NAME} init [--platform web|ios|android|mac] [--app-id <id>] [--url <https-url>] [--force] [--yes] [--no-install]
15
+ Install Verify, write verify.config.ts and an example script, then check authentication
14
16
  npx ${PACKAGE_NAME} whoami
15
17
  Check which Revyl account your credentials belong to
16
18
 
@@ -50,8 +52,11 @@ async function initCommand(args) {
50
52
  'app-id': { type: 'string' },
51
53
  url: { type: 'string' },
52
54
  force: { type: 'boolean' },
55
+ yes: { type: 'boolean' },
56
+ 'no-install': { type: 'boolean' },
53
57
  },
54
58
  });
59
+ const interactive = Boolean(process.stdin.isTTY) && !process.env.CI;
55
60
  let answers;
56
61
  if (values.platform) {
57
62
  answers = validateInitAnswers({
@@ -60,23 +65,52 @@ async function initCommand(args) {
60
65
  ...(values.url ? { url: values.url } : {}),
61
66
  });
62
67
  }
63
- else if (process.stdin.isTTY) {
64
- answers = await askInitQuestions(values['app-id'], values.url);
65
- }
66
- else {
68
+ else if (!interactive) {
67
69
  throw new VerifyError('INVALID_ARGUMENT', 'Pass --platform web, ios, android, or mac when not running in a terminal.');
68
70
  }
71
+ if (!values['no-install'] && !(await packageHasVerify(process.cwd()))) {
72
+ let installApproved = !interactive || Boolean(values.yes);
73
+ if (interactive && !values.yes) {
74
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
75
+ try {
76
+ const answer = (await readline.question(`Install ${PACKAGE_NAME} as a dev dependency? (Y/n) `)).trim().toLowerCase();
77
+ installApproved = answer === '' || answer === 'y' || answer === 'yes';
78
+ }
79
+ finally {
80
+ readline.close();
81
+ }
82
+ }
83
+ if (installApproved)
84
+ await installVerify(process.cwd());
85
+ }
86
+ answers ??= await askInitQuestions(values['app-id'], values.url);
69
87
  const result = await initProject(process.cwd(), answers, { force: values.force ?? false });
88
+ process.stdout.write(`Created ${result.created.join(' and ')}${result.gitignoreUpdated ? ', and added .verify/ to .gitignore' : ''}.\n`);
89
+ const credentials = await resolveCredentials();
90
+ if (!credentials) {
91
+ if (interactive) {
92
+ await signIn();
93
+ }
94
+ else {
95
+ throw new VerifyError('AUTH_MISSING', `${missingCredentialsMessage()} In non-interactive environments, set the REVYL_API_KEY secret.`);
96
+ }
97
+ }
98
+ try {
99
+ await whoamiCommand();
100
+ }
101
+ catch (error) {
102
+ if (!interactive || !isVerifyError(error) || error.code !== 'AUTH_REJECTED')
103
+ throw error;
104
+ await signIn({ force: true });
105
+ await whoamiCommand();
106
+ }
70
107
  const steps = [
71
- `npm install --save-dev ${PACKAGE_NAME}`,
72
- "Authenticate: export REVYL_API_KEY=<key from Revyl settings>, or run 'revyl auth login'",
73
- ...(answers.platform === 'mac' ? [`Mac computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations; use the optional REVYL_BACKEND_URL override with staging credentials. Ask support@revyl.ai for access. See https://github.com/RevylAI/verify/blob/v${VERSION}/packages/verify/docs/computers.md#availability-and-errors`] : []),
108
+ 'Configure the generated verify.config.ts if needed',
109
+ ...(answers.platform === 'mac' ? ['Mac computers are early access. Ask support@revyl.ai about access.'] : []),
74
110
  ...(answers.platform === 'mac' || answers.appId ? [] : ['Put your Revyl app id in verify.config.ts (or set REVYL_APP_ID)']),
75
111
  'node verify/example.ts',
76
112
  ];
77
113
  process.stdout.write([
78
- `Created ${result.created.join(' and ')}${result.gitignoreUpdated ? ', and added .verify/ to .gitignore' : ''}.`,
79
- '',
80
114
  'Next:',
81
115
  ...steps.map((step, index) => ` ${index + 1}. ${step}`),
82
116
  '',
@@ -1,22 +1,32 @@
1
1
  import { RevylClient } from './api/client.js';
2
2
  import type { ComputerPlatform } from './api/types.js';
3
- import type { ResolvedComputerTarget } from './config.js';
4
- import { type ComputerProcess, type ComputerRecording, type ExecOptions, type ExecResult, type FileStat, type OperationOptions, type Session, type SessionOptions, type StepResult } from './session.js';
3
+ import { type HostedTargetConfig, type ResolvedComputerTarget } from './config.js';
4
+ import { type ComputerProcess, type ComputerRecording, type ExecOptions, type ExecResult, type FileStat, type OpenedSession, type OperationOptions, type Session, type SessionOptions, type StepResult } from './session.js';
5
5
  export declare class ComputerSession implements Session {
6
6
  private readonly client;
7
7
  private readonly options;
8
+ private readonly stopOnClose;
8
9
  readonly platform: ComputerPlatform;
9
10
  readonly target: string;
10
- readonly id: `${string}-${string}-${string}-${string}-${string}`;
11
+ readonly id: string;
11
12
  readonly viewerUrl: string;
12
13
  readonly steps: StepResult[];
13
14
  private readonly lifetime;
14
15
  private closePromise;
15
16
  private expiresAtMs;
16
17
  private screenReadyUntilMs;
18
+ private readonly children;
19
+ private readonly childStarts;
20
+ private closing;
17
21
  private constructor();
18
22
  static start(client: RevylClient, target: ResolvedComputerTarget, options: SessionOptions): Promise<ComputerSession>;
19
23
  get closed(): boolean;
24
+ static attach(client: RevylClient, computerId: string, options: SessionOptions & {
25
+ stopOnClose?: boolean;
26
+ }): Promise<ComputerSession>;
27
+ open(target: HostedTargetConfig): Promise<OpenedSession>;
28
+ report(): Promise<Record<string, unknown>>;
29
+ navigate(): Promise<never>;
20
30
  get expiresAt(): Date | undefined;
21
31
  recording(): Promise<ComputerRecording | null>;
22
32
  instruction(): Promise<never>;
@@ -34,7 +44,7 @@ export declare class ComputerSession implements Session {
34
44
  [Symbol.asyncDispose](): Promise<void>;
35
45
  private operation;
36
46
  private request;
37
- private report;
47
+ private parseReport;
38
48
  private assertOpen;
39
49
  private observationSignal;
40
50
  private unavailable;
@@ -2,34 +2,41 @@ 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, responseErrorCode, 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 COMPUTER_AVAILABILITY = 'Computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations and operating systems. 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.';
13
14
  const COMPUTER_CAPACITY = 'Computer admission was refused because of queued work (including iOS) or the concurrency limit. Verify will not retry the start.';
14
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']);
15
16
  export class ComputerSession {
16
17
  client;
17
18
  options;
19
+ stopOnClose;
18
20
  platform;
19
21
  target;
20
- id = randomUUID();
22
+ id;
21
23
  viewerUrl;
22
24
  steps = [];
23
25
  lifetime = new AbortController();
24
26
  closePromise;
25
27
  expiresAtMs;
26
28
  screenReadyUntilMs = 0;
27
- constructor(client, target, options) {
29
+ children = new Set();
30
+ childStarts = new Set();
31
+ closing = false;
32
+ constructor(client, target, options, id = randomUUID(), stopOnClose = true) {
28
33
  this.client = client;
29
34
  this.options = options;
35
+ this.stopOnClose = stopOnClose;
36
+ this.id = id;
30
37
  this.target = target.name;
31
38
  this.platform = target.platform;
32
- this.viewerUrl = sessionViewerUrl(this.id, 'computer');
39
+ this.viewerUrl = sessionViewerUrl(this.id, 'computer', options.appUrl);
33
40
  }
34
41
  static async start(client, target, options) {
35
42
  const session = new ComputerSession(client, target, options);
@@ -40,7 +47,7 @@ export class ComputerSession {
40
47
  session.log(`starting computer ${session.viewerUrl}. ${COMPUTER_AVAILABILITY}`);
41
48
  const response = await session.request('POST', '', { computer_session_id: session.id, os: target.platform === 'mac' ? 'macos' : 'linux' }, signal);
42
49
  createAcknowledged = true;
43
- let report = session.report(response);
50
+ let report = session.parseReport(response);
44
51
  for (;;) {
45
52
  if (report.state === 'active')
46
53
  break;
@@ -49,7 +56,7 @@ export class ComputerSession {
49
56
  throw new VerifyError('SESSION_START_FAILED', `The ${session.target} computer ended before it was ready (${report.state}).${reason} See ${session.viewerUrl}`);
50
57
  }
51
58
  await delay(2_000, undefined, { signal });
52
- report = session.report(await session.request('GET', '/report', undefined, signal));
59
+ report = session.parseReport(await session.request('GET', '/report', undefined, signal));
53
60
  }
54
61
  session.screenReadyUntilMs = Date.now() + 30_000;
55
62
  session.log('ready');
@@ -57,7 +64,7 @@ export class ComputerSession {
57
64
  }
58
65
  catch (error) {
59
66
  const timedOut = signal.aborted;
60
- if (!createAcknowledged && isVerifyError(error) && ['AUTH_REJECTED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT', 'COMPUTER_LIMIT'].includes(error.code)) {
67
+ if (!createAcknowledged && isVerifyError(error) && ['AUTH_REJECTED', 'EDGE_BLOCKED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT', 'COMPUTER_LIMIT'].includes(error.code)) {
61
68
  session.lifetime.abort();
62
69
  untrackSession(session);
63
70
  }
@@ -65,19 +72,63 @@ export class ComputerSession {
65
72
  await session.close().catch(() => undefined);
66
73
  }
67
74
  if (timedOut)
68
- 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}`);
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}`);
69
76
  throw error;
70
77
  }
71
78
  }
72
79
  get closed() {
73
80
  return this.closePromise !== undefined;
74
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
+ }
75
126
  get expiresAt() {
76
127
  return this.expiresAtMs === undefined ? undefined : new Date(this.expiresAtMs);
77
128
  }
78
129
  async recording() {
79
130
  const signal = AbortSignal.timeout(30_000);
80
- const report = this.report(await this.request('GET', '/report', undefined, signal));
131
+ const report = this.parseReport(await this.request('GET', '/report', undefined, signal));
81
132
  if (!['stopped', 'failed', 'owner_lost'].includes(report.state) || report.release !== 'confirmed') {
82
133
  throw new VerifyError('SESSION_BUSY', `The computer recording can be requested only after terminal state and confirmed release. See ${this.viewerUrl}`);
83
134
  }
@@ -206,18 +257,34 @@ export class ComputerSession {
206
257
  }
207
258
  close() {
208
259
  this.closePromise ??= (async () => {
260
+ this.closing = true;
209
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();
210
269
  try {
211
- 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)));
212
277
  if (report.release !== 'confirmed') {
213
278
  throw new VerifyError('ACTION_UNCERTAIN', `Computer stop was requested, but physical release is unconfirmed. Inspect ${this.viewerUrl}`);
214
279
  }
215
280
  if (!['stopped', 'failed', 'owner_lost'].includes(report.state))
216
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');
217
284
  this.log('closed');
218
285
  }
219
286
  catch (error) {
220
- if (isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
287
+ if (this.stopOnClose && isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
221
288
  const message = `${error.message} A possible cause is that no computer could be assigned. ${COMPUTER_AVAILABILITY}`;
222
289
  this.log(`close failed (ACTION_UNCERTAIN); ${message}`);
223
290
  throw new VerifyError('ACTION_UNCERTAIN', message, { cause: error });
@@ -239,8 +306,17 @@ export class ComputerSession {
239
306
  const requestId = randomUUID();
240
307
  const operationId = computerOperationId(this.id, requestId);
241
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
+ }
242
318
  try {
243
- 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);
244
320
  for (;;) {
245
321
  if (signal.aborted)
246
322
  throw signal.reason;
@@ -303,9 +379,13 @@ export class ComputerSession {
303
379
  }
304
380
  if (response.status >= 200 && response.status < 300)
305
381
  return response;
382
+ const blocked = edgeBlockedError(response);
383
+ if (blocked)
384
+ throw blocked;
306
385
  if (suffix === '/recording' && response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
307
386
  return response;
308
- const detail = responseDetail(response);
387
+ const detail = responseRefusalReason(response) ?? responseDetail(response);
388
+ const refusal = { reason: responseRefusalReason(response), httpStatus: response.status };
309
389
  if (suffix === '/screen/screenshot') {
310
390
  let raw = null;
311
391
  try {
@@ -317,12 +397,14 @@ export class ComputerSession {
317
397
  return response;
318
398
  }
319
399
  if (detail === 'computer_not_enabled' || (response.status === 503 && detail === 'runtime_unavailable')) {
320
- throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Revyl refused the computer request (${detail}). ${COMPUTER_AVAILABILITY}. Ask support@revyl.ai about staging organization enablement. See ${this.viewerUrl}`);
400
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Revyl refused the computer request (${detail}). ${COMPUTER_AVAILABILITY} See ${this.viewerUrl}`, refusal);
321
401
  }
322
402
  if (response.status === 429 && detail === 'computer_limit')
323
- throw new VerifyError('COMPUTER_LIMIT', `${COMPUTER_CAPACITY} See ${this.viewerUrl}`);
403
+ throw new VerifyError('COMPUTER_LIMIT', `${COMPUTER_CAPACITY} See ${this.viewerUrl}`, refusal);
324
404
  let code = 'API_ERROR';
325
- if (detail === 'run_closed')
405
+ if (response.status === 401 || response.status === 403)
406
+ code = 'AUTH_REJECTED';
407
+ else if (detail === 'run_closed')
326
408
  code = 'SESSION_ENDED';
327
409
  else if (detail === 'inaccessible' || detail === 'invalid_handle')
328
410
  code = 'NOT_FOUND';
@@ -330,8 +412,6 @@ export class ComputerSession {
330
412
  code = 'ACTION_UNCERTAIN';
331
413
  else if (detail === 'invalid_request' || response.status === 422)
332
414
  code = 'INVALID_ARGUMENT';
333
- else if (response.status === 401 || response.status === 403)
334
- code = 'AUTH_REJECTED';
335
415
  else if (detail === 'start_failed')
336
416
  code = 'SESSION_START_FAILED';
337
417
  else if (response.status === 409 || detail === 'operation_limit')
@@ -341,9 +421,9 @@ export class ComputerSession {
341
421
  else if (method === 'POST' && (response.status >= 500 || response.status < 400))
342
422
  code = 'ACTION_UNCERTAIN';
343
423
  const reason = detail && ERROR_CODES.has(detail) ? detail : `HTTP ${response.status}`;
344
- 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);
345
425
  }
346
- report(response) {
426
+ parseReport(response) {
347
427
  const report = parseComputerJson(response);
348
428
  if (!report || report.computer_session_id !== this.id || !STATES.has(report.state))
349
429
  throw invalidResponse();
@@ -352,7 +432,7 @@ export class ComputerSession {
352
432
  return report;
353
433
  }
354
434
  assertOpen() {
355
- if (this.closed || (this.expiresAtMs !== undefined && Date.now() >= this.expiresAtMs))
435
+ if (this.closing || this.closed || (this.expiresAtMs !== undefined && Date.now() >= this.expiresAtMs))
356
436
  throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed or expired.`);
357
437
  }
358
438
  observationSignal(timeoutMs) {
@@ -379,6 +459,9 @@ function operationTimeout(options) {
379
459
  throw new VerifyError('INVALID_ARGUMENT', 'Computer operation timeout must be an integer from 1 to 300000 ms.');
380
460
  return timeout;
381
461
  }
462
+ function encodeWireText(value) {
463
+ return Buffer.from(value, 'utf8').toString('base64').replaceAll('+', '-').replaceAll('/', '_');
464
+ }
382
465
  function guestPath(path) {
383
466
  if (typeof path !== 'string' || !path || Buffer.byteLength(path) > 1024 || path.split('/').some((part) => ['', '.', '..'].includes(part)) || /[\x00-\x1f\x7f\\:]/.test(path))
384
467
  throw new VerifyError('INVALID_ARGUMENT', 'Computer paths must be workspace-relative, without empty, dot, parent, backslash, colon, or control-character components.');
package/dist/config.d.ts CHANGED
@@ -52,6 +52,12 @@ export interface ComputerTargetConfig {
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 {};