@revyl/verify 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +201 -0
- package/README.md +137 -2
- package/SECURITY.md +13 -0
- package/dist/api/client.d.ts +63 -0
- package/dist/api/client.js +201 -0
- package/dist/api/types.d.ts +222 -0
- package/dist/api/types.js +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +65 -0
- package/dist/cleanup.d.ts +13 -0
- package/dist/cleanup.js +72 -0
- package/dist/cli/init.d.ts +14 -0
- package/dist/cli/init.js +115 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.js +127 -0
- package/dist/computer-session.d.ts +43 -0
- package/dist/computer-session.js +418 -0
- package/dist/config.d.ts +88 -0
- package/dist/config.js +212 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +14 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +3 -0
- package/dist/open.d.ts +13 -0
- package/dist/open.js +33 -0
- package/dist/screen-readiness.d.ts +1 -0
- package/dist/screen-readiness.js +27 -0
- package/dist/session.d.ts +153 -0
- package/dist/session.js +443 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +6 -0
- package/docs/README.md +19 -0
- package/docs/agents.md +43 -0
- package/docs/api.md +120 -0
- package/docs/ci.md +70 -0
- package/docs/computers.md +83 -0
- package/docs/configuration.md +92 -0
- package/docs/end-to-end.md +105 -0
- package/docs/errors.md +39 -0
- package/docs/getting-started.md +88 -0
- package/docs/linux-computers-contract.md +53 -0
- package/docs/llms.txt +19 -0
- package/docs/mobile.md +49 -0
- package/docs/web.md +37 -0
- package/package.json +71 -4
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { RevylClient } from './api/client.js';
|
|
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';
|
|
5
|
+
export declare class ComputerSession implements Session {
|
|
6
|
+
private readonly client;
|
|
7
|
+
private readonly options;
|
|
8
|
+
readonly platform: ComputerPlatform;
|
|
9
|
+
readonly target: string;
|
|
10
|
+
readonly id: `${string}-${string}-${string}-${string}-${string}`;
|
|
11
|
+
readonly viewerUrl: string;
|
|
12
|
+
readonly steps: StepResult[];
|
|
13
|
+
private readonly lifetime;
|
|
14
|
+
private closePromise;
|
|
15
|
+
private expiresAtMs;
|
|
16
|
+
private screenReadyUntilMs;
|
|
17
|
+
private constructor();
|
|
18
|
+
static start(client: RevylClient, target: ResolvedComputerTarget, options: SessionOptions): Promise<ComputerSession>;
|
|
19
|
+
get closed(): boolean;
|
|
20
|
+
get expiresAt(): Date | undefined;
|
|
21
|
+
recording(): Promise<ComputerRecording | null>;
|
|
22
|
+
instruction(): Promise<never>;
|
|
23
|
+
validation(): Promise<never>;
|
|
24
|
+
screenshot(options?: {
|
|
25
|
+
path?: string;
|
|
26
|
+
}): Promise<Uint8Array>;
|
|
27
|
+
exec(argv: string[], options?: ExecOptions): Promise<ExecResult>;
|
|
28
|
+
readFile(path: string, options?: OperationOptions): Promise<Buffer>;
|
|
29
|
+
writeFile(path: string, data: string | Uint8Array, options?: OperationOptions): Promise<void>;
|
|
30
|
+
stat(path: string, options?: OperationOptions): Promise<FileStat>;
|
|
31
|
+
processes(options?: OperationOptions): Promise<ComputerProcess[]>;
|
|
32
|
+
terminate(processId: string, options?: OperationOptions): Promise<void>;
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
35
|
+
private operation;
|
|
36
|
+
private request;
|
|
37
|
+
private report;
|
|
38
|
+
private assertOpen;
|
|
39
|
+
private observationSignal;
|
|
40
|
+
private unavailable;
|
|
41
|
+
private log;
|
|
42
|
+
}
|
|
43
|
+
export declare function computerOperationId(sessionId: string, requestId: string): string;
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { responseDetail, responseErrorCode, sessionViewerUrl } from './api/client.js';
|
|
6
|
+
import { trackSession, untrackSession } from './cleanup.js';
|
|
7
|
+
import { VerifyError, isVerifyError } from './errors.js';
|
|
8
|
+
import { pngSize } from './session.js';
|
|
9
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
const STATES = new Set(['starting', 'active', 'closing', 'stopped', 'failed', 'owner_lost']);
|
|
11
|
+
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_CAPACITY = 'Computer admission was refused because of queued work (including iOS) or the concurrency limit. Verify will not retry the start.';
|
|
14
|
+
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
|
+
export class ComputerSession {
|
|
16
|
+
client;
|
|
17
|
+
options;
|
|
18
|
+
platform;
|
|
19
|
+
target;
|
|
20
|
+
id = randomUUID();
|
|
21
|
+
viewerUrl;
|
|
22
|
+
steps = [];
|
|
23
|
+
lifetime = new AbortController();
|
|
24
|
+
closePromise;
|
|
25
|
+
expiresAtMs;
|
|
26
|
+
screenReadyUntilMs = 0;
|
|
27
|
+
constructor(client, target, options) {
|
|
28
|
+
this.client = client;
|
|
29
|
+
this.options = options;
|
|
30
|
+
this.target = target.name;
|
|
31
|
+
this.platform = target.platform;
|
|
32
|
+
this.viewerUrl = sessionViewerUrl(this.id, 'computer');
|
|
33
|
+
}
|
|
34
|
+
static async start(client, target, options) {
|
|
35
|
+
const session = new ComputerSession(client, target, options);
|
|
36
|
+
const signal = AbortSignal.any([AbortSignal.timeout(target.startTimeout), session.lifetime.signal]);
|
|
37
|
+
let createAcknowledged = false;
|
|
38
|
+
trackSession(session);
|
|
39
|
+
try {
|
|
40
|
+
session.log(`starting computer ${session.viewerUrl}. ${COMPUTER_AVAILABILITY}`);
|
|
41
|
+
const response = await session.request('POST', '', { computer_session_id: session.id, os: target.platform === 'mac' ? 'macos' : 'linux' }, signal);
|
|
42
|
+
createAcknowledged = true;
|
|
43
|
+
let report = session.report(response);
|
|
44
|
+
for (;;) {
|
|
45
|
+
if (report.state === 'active')
|
|
46
|
+
break;
|
|
47
|
+
if (report.state !== 'starting') {
|
|
48
|
+
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}` : '';
|
|
49
|
+
throw new VerifyError('SESSION_START_FAILED', `The ${session.target} computer ended before it was ready (${report.state}).${reason} See ${session.viewerUrl}`);
|
|
50
|
+
}
|
|
51
|
+
await delay(2_000, undefined, { signal });
|
|
52
|
+
report = session.report(await session.request('GET', '/report', undefined, signal));
|
|
53
|
+
}
|
|
54
|
+
session.screenReadyUntilMs = Date.now() + 30_000;
|
|
55
|
+
session.log('ready');
|
|
56
|
+
return session;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const timedOut = signal.aborted;
|
|
60
|
+
if (!createAcknowledged && isVerifyError(error) && ['AUTH_REJECTED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT', 'COMPUTER_LIMIT'].includes(error.code)) {
|
|
61
|
+
session.lifetime.abort();
|
|
62
|
+
untrackSession(session);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await session.close().catch(() => undefined);
|
|
66
|
+
}
|
|
67
|
+
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}`);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
get closed() {
|
|
73
|
+
return this.closePromise !== undefined;
|
|
74
|
+
}
|
|
75
|
+
get expiresAt() {
|
|
76
|
+
return this.expiresAtMs === undefined ? undefined : new Date(this.expiresAtMs);
|
|
77
|
+
}
|
|
78
|
+
async recording() {
|
|
79
|
+
const signal = AbortSignal.timeout(30_000);
|
|
80
|
+
const report = this.report(await this.request('GET', '/report', undefined, signal));
|
|
81
|
+
if (!['stopped', 'failed', 'owner_lost'].includes(report.state) || report.release !== 'confirmed') {
|
|
82
|
+
throw new VerifyError('SESSION_BUSY', `The computer recording can be requested only after terminal state and confirmed release. See ${this.viewerUrl}`);
|
|
83
|
+
}
|
|
84
|
+
const response = await this.request('GET', '/recording', undefined, signal);
|
|
85
|
+
if (response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
|
|
86
|
+
return null;
|
|
87
|
+
const raw = parseComputerJson(response);
|
|
88
|
+
if (!raw || typeof raw.url !== 'string' || !/^https:\/\//i.test(raw.url) || /[\x00-\x20\x7f]/.test(raw.url))
|
|
89
|
+
throw invalidResponse();
|
|
90
|
+
let url;
|
|
91
|
+
try {
|
|
92
|
+
url = new URL(raw.url);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw invalidResponse();
|
|
96
|
+
}
|
|
97
|
+
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)))
|
|
98
|
+
throw invalidResponse();
|
|
99
|
+
const startedAt = raw.started_at == null ? undefined : parseTimestamp(raw.started_at);
|
|
100
|
+
return { url: raw.url, ...(raw.duration_seconds != null ? { durationSeconds: raw.duration_seconds } : {}), ...(startedAt ? { startedAt } : {}) };
|
|
101
|
+
}
|
|
102
|
+
instruction() {
|
|
103
|
+
return this.unavailable('instruction');
|
|
104
|
+
}
|
|
105
|
+
validation() {
|
|
106
|
+
return this.unavailable('validation');
|
|
107
|
+
}
|
|
108
|
+
async screenshot(options = {}) {
|
|
109
|
+
this.assertOpen();
|
|
110
|
+
const signal = this.observationSignal(30_000);
|
|
111
|
+
let retrySignal = signal;
|
|
112
|
+
let response;
|
|
113
|
+
let transient = false;
|
|
114
|
+
let backoffMs = 500;
|
|
115
|
+
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}`);
|
|
116
|
+
try {
|
|
117
|
+
for (;;) {
|
|
118
|
+
this.assertOpen();
|
|
119
|
+
response = await this.request('GET', '/screen/screenshot', undefined, retrySignal);
|
|
120
|
+
if (response.status >= 200 && response.status < 300)
|
|
121
|
+
break;
|
|
122
|
+
const remainingMs = this.screenReadyUntilMs - Date.now();
|
|
123
|
+
if (remainingMs <= 0)
|
|
124
|
+
throw notReady();
|
|
125
|
+
if (!transient)
|
|
126
|
+
retrySignal = AbortSignal.any([signal, AbortSignal.timeout(remainingMs)]);
|
|
127
|
+
transient = true;
|
|
128
|
+
await delay(Math.min(backoffMs, remainingMs), undefined, { signal: retrySignal });
|
|
129
|
+
if (Date.now() >= this.screenReadyUntilMs)
|
|
130
|
+
throw notReady();
|
|
131
|
+
backoffMs = Math.min(backoffMs * 2, 2_000);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (transient && !this.lifetime.signal.aborted && (this.expiresAtMs === undefined || Date.now() < this.expiresAtMs) && retrySignal.aborted && Date.now() >= this.screenReadyUntilMs)
|
|
136
|
+
throw notReady();
|
|
137
|
+
if (retrySignal.aborted)
|
|
138
|
+
throw new VerifyError('TIMEOUT', `Computer screenshot observation was cancelled or exceeded its deadline. See ${this.viewerUrl}`);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
this.assertOpen();
|
|
142
|
+
const raw = parseComputerJson(response);
|
|
143
|
+
const bytes = decodeBytes(raw?.png, 128 * MAX_OUTPUT_BYTES);
|
|
144
|
+
const size = pngSize(bytes);
|
|
145
|
+
if (!size || size.width > 4095 || size.height > 4095 || size.width !== raw.width || size.height !== raw.height)
|
|
146
|
+
throw invalidResponse();
|
|
147
|
+
if (options.path) {
|
|
148
|
+
const path = resolve(options.path);
|
|
149
|
+
await mkdir(dirname(path), { recursive: true });
|
|
150
|
+
await writeFile(path, bytes);
|
|
151
|
+
}
|
|
152
|
+
return bytes;
|
|
153
|
+
}
|
|
154
|
+
async exec(argv, options = {}) {
|
|
155
|
+
const stdin = options.stdin === undefined ? Buffer.alloc(0) : inputBytes(options.stdin);
|
|
156
|
+
if (!Array.isArray(argv) || argv.length < 1 || argv.length > 64 || !argv[0] || argv.some((arg) => typeof arg !== 'string' || arg.includes('\0')) || argv.reduce((sum, arg) => sum + Buffer.byteLength(arg), 0) + stdin.length > 16_384) {
|
|
157
|
+
throw new VerifyError('INVALID_ARGUMENT', 'exec() takes 1-64 string arguments without NUL bytes; argv and stdin together must fit in 16384 bytes.');
|
|
158
|
+
}
|
|
159
|
+
const result = await this.operation({ kind: 'exec', argv, stdin: stdin.toString('base64'), timeout_ms: operationTimeout(options) }, options.check === false);
|
|
160
|
+
if (result.kind !== 'exec' || !Number.isInteger(result.exit_code) || result.exit_code < -255 || result.exit_code > 255)
|
|
161
|
+
throw invalidResponse();
|
|
162
|
+
const stdout = decodeBytes(result.stdout ?? '', MAX_OUTPUT_BYTES);
|
|
163
|
+
const stderr = decodeBytes(result.stderr ?? '', MAX_OUTPUT_BYTES);
|
|
164
|
+
if (stdout.length + stderr.length > MAX_OUTPUT_BYTES)
|
|
165
|
+
throw invalidResponse();
|
|
166
|
+
return { exitCode: result.exit_code, stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8') };
|
|
167
|
+
}
|
|
168
|
+
async readFile(path, options = {}) {
|
|
169
|
+
const result = await this.operation({ kind: 'file_read', path: guestPath(path), timeout_ms: operationTimeout(options) });
|
|
170
|
+
if (result.kind !== 'file_read')
|
|
171
|
+
throw invalidResponse();
|
|
172
|
+
return decodeBytes(result.data, MAX_OUTPUT_BYTES);
|
|
173
|
+
}
|
|
174
|
+
async writeFile(path, data, options = {}) {
|
|
175
|
+
const bytes = inputBytes(data);
|
|
176
|
+
if (bytes.length > 65_536)
|
|
177
|
+
throw new VerifyError('INVALID_ARGUMENT', 'writeFile() takes at most 65536 bytes.');
|
|
178
|
+
const result = await this.operation({ kind: 'file_write', path: guestPath(path), data: bytes.toString('base64'), timeout_ms: operationTimeout(options) });
|
|
179
|
+
if (result.kind !== 'file_write' || result.byte_count !== bytes.length)
|
|
180
|
+
throw invalidResponse();
|
|
181
|
+
}
|
|
182
|
+
async stat(path, options = {}) {
|
|
183
|
+
const result = await this.operation({ kind: 'file_stat', path: guestPath(path), timeout_ms: operationTimeout(options) });
|
|
184
|
+
if (result.kind !== 'file_stat' || !['file', 'directory'].includes(result.file_kind) || !Number.isSafeInteger(result.size_bytes) || result.size_bytes < 0)
|
|
185
|
+
throw invalidResponse();
|
|
186
|
+
return { kind: result.file_kind, size: result.size_bytes };
|
|
187
|
+
}
|
|
188
|
+
async processes(options = {}) {
|
|
189
|
+
const result = await this.operation({ kind: 'process_list', timeout_ms: operationTimeout(options) });
|
|
190
|
+
if (result.kind !== 'process_list' || !Array.isArray(result.processes) || result.processes.length > 64)
|
|
191
|
+
throw invalidResponse();
|
|
192
|
+
const ids = new Set();
|
|
193
|
+
return result.processes.map((process) => {
|
|
194
|
+
if (!process || process.handle?.computer_session_id !== this.id || !UUID.test(process.handle.handle_id) || !['running', 'exited'].includes(process.state) || (process.exit_code != null && (!Number.isInteger(process.exit_code) || process.exit_code < -255 || process.exit_code > 255)) || (process.state === 'running' && process.exit_code != null) || ids.has(process.handle.handle_id))
|
|
195
|
+
throw invalidResponse();
|
|
196
|
+
ids.add(process.handle.handle_id);
|
|
197
|
+
return { id: process.handle.handle_id, state: process.state, ...(process.exit_code != null ? { exitCode: process.exit_code } : {}) };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async terminate(processId, options = {}) {
|
|
201
|
+
if (typeof processId !== 'string' || !UUID.test(processId))
|
|
202
|
+
throw new VerifyError('INVALID_ARGUMENT', 'terminate() takes a process handle id from processes(), not an OS PID.');
|
|
203
|
+
const result = await this.operation({ kind: 'process_terminate', handle: { computer_session_id: this.id, handle_id: processId }, timeout_ms: operationTimeout(options) });
|
|
204
|
+
if (result.kind !== 'process_terminate' || result.termination !== 'confirmed')
|
|
205
|
+
throw invalidResponse();
|
|
206
|
+
}
|
|
207
|
+
close() {
|
|
208
|
+
this.closePromise ??= (async () => {
|
|
209
|
+
this.lifetime.abort();
|
|
210
|
+
try {
|
|
211
|
+
const report = this.report(await this.request('POST', '/stop', undefined, AbortSignal.timeout(310_000)));
|
|
212
|
+
if (report.release !== 'confirmed') {
|
|
213
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer stop was requested, but physical release is unconfirmed. Inspect ${this.viewerUrl}`);
|
|
214
|
+
}
|
|
215
|
+
if (!['stopped', 'failed', 'owner_lost'].includes(report.state))
|
|
216
|
+
throw invalidResponse();
|
|
217
|
+
this.log('closed');
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
|
|
221
|
+
const message = `${error.message} A possible cause is that no computer could be assigned. ${COMPUTER_AVAILABILITY}`;
|
|
222
|
+
this.log(`close failed (ACTION_UNCERTAIN); ${message}`);
|
|
223
|
+
throw new VerifyError('ACTION_UNCERTAIN', message, { cause: error });
|
|
224
|
+
}
|
|
225
|
+
this.log(`close failed (${isVerifyError(error) ? error.code : 'API_ERROR'}); inspect ${this.viewerUrl}`);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
untrackSession(this);
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
return this.closePromise;
|
|
233
|
+
}
|
|
234
|
+
[Symbol.asyncDispose]() {
|
|
235
|
+
return this.close();
|
|
236
|
+
}
|
|
237
|
+
async operation(command, allowNonzero = false) {
|
|
238
|
+
this.assertOpen();
|
|
239
|
+
const requestId = randomUUID();
|
|
240
|
+
const operationId = computerOperationId(this.id, requestId);
|
|
241
|
+
const signal = this.observationSignal(310_000);
|
|
242
|
+
try {
|
|
243
|
+
let response = await this.request('POST', '/operations', { computer_session_id: this.id, request_id: requestId, command }, signal);
|
|
244
|
+
for (;;) {
|
|
245
|
+
if (signal.aborted)
|
|
246
|
+
throw signal.reason;
|
|
247
|
+
if (response.status !== 202) {
|
|
248
|
+
const body = parseComputerJson(response);
|
|
249
|
+
if (body?.computer_session_id !== this.id || body.operation_id !== operationId || !['recorded', 'pending', 'failed'].includes(body.evidence) || !body.execution)
|
|
250
|
+
throw invalidResponse();
|
|
251
|
+
const execution = body.execution;
|
|
252
|
+
if (execution.outcome === 'succeeded' || (execution.outcome === 'failed' && execution.error_code === 'exec_nonzero' && allowNonzero)) {
|
|
253
|
+
const result = execution.result;
|
|
254
|
+
if (!result || result.kind !== command.kind || (execution.outcome === 'succeeded' && result.kind === 'exec' && result.exit_code !== 0) || (execution.outcome === 'failed' && result.kind === 'exec' && result.exit_code === 0))
|
|
255
|
+
throw invalidResponse();
|
|
256
|
+
if (body.evidence !== 'recorded')
|
|
257
|
+
this.log(`operation ${operationId}: execution observed, report evidence ${body.evidence}`);
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
if (execution.outcome === 'failed') {
|
|
261
|
+
if (execution.error_code === 'exec_nonzero' && (execution.result?.kind !== 'exec' || !Number.isInteger(execution.result.exit_code) || execution.result.exit_code === 0 || command.kind !== 'exec'))
|
|
262
|
+
throw invalidResponse();
|
|
263
|
+
const code = { exec_nonzero: 'COMMAND_FAILED', not_found: 'NOT_FOUND', access_denied: 'ACCESS_DENIED' }[execution.error_code];
|
|
264
|
+
if (!code)
|
|
265
|
+
throw invalidResponse();
|
|
266
|
+
throw new VerifyError(code, `Computer ${command.kind} failed (${execution.error_code}${execution.result ? `, exit code ${execution.result.exit_code}` : ''}). See ${this.viewerUrl}`);
|
|
267
|
+
}
|
|
268
|
+
if (execution.outcome === 'timed_out') {
|
|
269
|
+
if (execution.termination === 'unknown')
|
|
270
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer ${command.kind} timed out; termination is unconfirmed. Verify will not repeat it. See ${this.viewerUrl}`);
|
|
271
|
+
if (execution.termination !== 'confirmed')
|
|
272
|
+
throw invalidResponse();
|
|
273
|
+
throw new VerifyError('TIMEOUT', `Computer ${command.kind} timed out; termination was confirmed. See ${this.viewerUrl}`);
|
|
274
|
+
}
|
|
275
|
+
if (execution.outcome !== 'unknown')
|
|
276
|
+
throw invalidResponse();
|
|
277
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer ${command.kind} outcome is unconfirmed. Verify will not repeat it. Inspect operation ${operationId} at ${this.viewerUrl}`);
|
|
278
|
+
}
|
|
279
|
+
if (parseComputerJson(response) !== null)
|
|
280
|
+
throw invalidResponse();
|
|
281
|
+
await delay(2_000, undefined, { signal });
|
|
282
|
+
response = await this.request('GET', `/operations/${operationId}`, undefined, signal);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (signal.aborted)
|
|
287
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer operation ${operationId} stopped being observed; its outcome is unconfirmed. See ${this.viewerUrl}`);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async request(method, suffix, body, signal) {
|
|
292
|
+
const path = `/api/v1/computer-runs${suffix === '' ? '' : `/${this.id}${suffix}`}`;
|
|
293
|
+
let response;
|
|
294
|
+
try {
|
|
295
|
+
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 });
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (method === 'POST' || suffix.startsWith('/operations/'))
|
|
299
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer request outcome is unconfirmed. Verify will not repeat it. See ${this.viewerUrl}`);
|
|
300
|
+
if (isVerifyError(error))
|
|
301
|
+
throw new VerifyError(error.code, `Could not read the computer state. See ${this.viewerUrl}`);
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
if (response.status >= 200 && response.status < 300)
|
|
305
|
+
return response;
|
|
306
|
+
if (suffix === '/recording' && response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
|
|
307
|
+
return response;
|
|
308
|
+
const detail = responseDetail(response);
|
|
309
|
+
if (suffix === '/screen/screenshot') {
|
|
310
|
+
let raw = null;
|
|
311
|
+
try {
|
|
312
|
+
raw = JSON.parse(new TextDecoder().decode(response.body));
|
|
313
|
+
}
|
|
314
|
+
catch { }
|
|
315
|
+
const code = raw?.detail ?? raw?.message ?? raw?.error;
|
|
316
|
+
if ((code === 'screen_unavailable' && (response.status === 409 || response.status === 503)) || (code === 'screen_starting' && response.status === 409))
|
|
317
|
+
return response;
|
|
318
|
+
}
|
|
319
|
+
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}`);
|
|
321
|
+
}
|
|
322
|
+
if (response.status === 429 && detail === 'computer_limit')
|
|
323
|
+
throw new VerifyError('COMPUTER_LIMIT', `${COMPUTER_CAPACITY} See ${this.viewerUrl}`);
|
|
324
|
+
let code = 'API_ERROR';
|
|
325
|
+
if (detail === 'run_closed')
|
|
326
|
+
code = 'SESSION_ENDED';
|
|
327
|
+
else if (detail === 'inaccessible' || detail === 'invalid_handle')
|
|
328
|
+
code = 'NOT_FOUND';
|
|
329
|
+
else if (detail === 'operation_not_found' || detail === 'owner_unavailable')
|
|
330
|
+
code = 'ACTION_UNCERTAIN';
|
|
331
|
+
else if (detail === 'invalid_request' || response.status === 422)
|
|
332
|
+
code = 'INVALID_ARGUMENT';
|
|
333
|
+
else if (response.status === 401 || response.status === 403)
|
|
334
|
+
code = 'AUTH_REJECTED';
|
|
335
|
+
else if (detail === 'start_failed')
|
|
336
|
+
code = 'SESSION_START_FAILED';
|
|
337
|
+
else if (response.status === 409 || detail === 'operation_limit')
|
|
338
|
+
code = 'SESSION_BUSY';
|
|
339
|
+
else if (suffix.startsWith('/operations/') && response.status >= 500)
|
|
340
|
+
code = 'ACTION_UNCERTAIN';
|
|
341
|
+
else if (method === 'POST' && (response.status >= 500 || response.status < 400))
|
|
342
|
+
code = 'ACTION_UNCERTAIN';
|
|
343
|
+
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}`);
|
|
345
|
+
}
|
|
346
|
+
report(response) {
|
|
347
|
+
const report = parseComputerJson(response);
|
|
348
|
+
if (!report || report.computer_session_id !== this.id || !STATES.has(report.state))
|
|
349
|
+
throw invalidResponse();
|
|
350
|
+
if (report.expires_at != null)
|
|
351
|
+
this.expiresAtMs = parseTimestamp(report.expires_at).getTime();
|
|
352
|
+
return report;
|
|
353
|
+
}
|
|
354
|
+
assertOpen() {
|
|
355
|
+
if (this.closed || (this.expiresAtMs !== undefined && Date.now() >= this.expiresAtMs))
|
|
356
|
+
throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed or expired.`);
|
|
357
|
+
}
|
|
358
|
+
observationSignal(timeoutMs) {
|
|
359
|
+
const remainingMs = this.expiresAtMs === undefined ? timeoutMs : Math.min(timeoutMs, this.expiresAtMs - Date.now());
|
|
360
|
+
return AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(Math.max(0, remainingMs))]);
|
|
361
|
+
}
|
|
362
|
+
unavailable(method) {
|
|
363
|
+
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on computers; use exec() and file operations. See ${this.viewerUrl}`));
|
|
364
|
+
}
|
|
365
|
+
log(line) {
|
|
366
|
+
this.options.log?.(`${this.target}: ${line}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
export function computerOperationId(sessionId, requestId) {
|
|
370
|
+
const hash = createHash('sha1').update(Buffer.from(sessionId.replaceAll('-', ''), 'hex')).update(requestId).digest();
|
|
371
|
+
hash[6] = (hash[6] & 0x0f) | 0x50;
|
|
372
|
+
hash[8] = (hash[8] & 0x3f) | 0x80;
|
|
373
|
+
const hex = hash.subarray(0, 16).toString('hex');
|
|
374
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
375
|
+
}
|
|
376
|
+
function operationTimeout(options) {
|
|
377
|
+
const timeout = options.timeout ?? 30_000;
|
|
378
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 300_000)
|
|
379
|
+
throw new VerifyError('INVALID_ARGUMENT', 'Computer operation timeout must be an integer from 1 to 300000 ms.');
|
|
380
|
+
return timeout;
|
|
381
|
+
}
|
|
382
|
+
function guestPath(path) {
|
|
383
|
+
if (typeof path !== 'string' || !path || Buffer.byteLength(path) > 1024 || path.split('/').some((part) => ['', '.', '..'].includes(part)) || /[\x00-\x1f\x7f\\:]/.test(path))
|
|
384
|
+
throw new VerifyError('INVALID_ARGUMENT', 'Computer paths must be workspace-relative, without empty, dot, parent, backslash, colon, or control-character components.');
|
|
385
|
+
return path;
|
|
386
|
+
}
|
|
387
|
+
function inputBytes(value) {
|
|
388
|
+
if (typeof value !== 'string' && !(value instanceof Uint8Array))
|
|
389
|
+
throw new VerifyError('INVALID_ARGUMENT', 'Computer input must be a string or Uint8Array.');
|
|
390
|
+
return Buffer.from(value);
|
|
391
|
+
}
|
|
392
|
+
function decodeBytes(value, maxBytes) {
|
|
393
|
+
if (typeof value !== 'string' || value.length > Math.ceil(maxBytes / 3) * 4 || value.length % 4 !== 0 || !/^[a-zA-Z0-9+/_-]*={0,2}$/.test(value))
|
|
394
|
+
throw invalidResponse();
|
|
395
|
+
const bytes = Buffer.from(value, 'base64');
|
|
396
|
+
if (bytes.length > maxBytes || bytes.toString('base64') !== value.replaceAll('-', '+').replaceAll('_', '/'))
|
|
397
|
+
throw invalidResponse();
|
|
398
|
+
return bytes;
|
|
399
|
+
}
|
|
400
|
+
function invalidResponse() {
|
|
401
|
+
return new VerifyError('API_ERROR', 'Revyl returned an invalid computer response.');
|
|
402
|
+
}
|
|
403
|
+
function parseTimestamp(value) {
|
|
404
|
+
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)))
|
|
405
|
+
throw invalidResponse();
|
|
406
|
+
const calendarTime = new Date(`${value.slice(0, 19)}Z`);
|
|
407
|
+
if (!Number.isFinite(calendarTime.getTime()) || calendarTime.toISOString().slice(0, 19) !== value.slice(0, 19))
|
|
408
|
+
throw invalidResponse();
|
|
409
|
+
return new Date(value);
|
|
410
|
+
}
|
|
411
|
+
function parseComputerJson(response) {
|
|
412
|
+
try {
|
|
413
|
+
return JSON.parse(new TextDecoder().decode(response.body));
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
throw invalidResponse();
|
|
417
|
+
}
|
|
418
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ComputerPlatform, MobilePlatform } from './api/types.js';
|
|
2
|
+
export interface WebAppConfig {
|
|
3
|
+
/** The Revyl web app this target belongs to. */
|
|
4
|
+
appId: string;
|
|
5
|
+
/** A public HTTPS URL to open, such as a preview deployment. Registered as a build each time a session opens. */
|
|
6
|
+
url?: string;
|
|
7
|
+
/** An exact Revyl build to open instead of a URL. */
|
|
8
|
+
buildId?: string;
|
|
9
|
+
/** Revyl branch to register the URL on, or to take the newest build from. */
|
|
10
|
+
branch?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface WebTargetConfig {
|
|
13
|
+
platform: 'web';
|
|
14
|
+
app: WebAppConfig;
|
|
15
|
+
viewport?: {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
deviceScaleFactor?: number;
|
|
19
|
+
};
|
|
20
|
+
/** A test-only sign-in route Revyl opens before the session starts. See docs/web.md. */
|
|
21
|
+
signIn?: {
|
|
22
|
+
path: string;
|
|
23
|
+
secret: string;
|
|
24
|
+
persona?: string;
|
|
25
|
+
};
|
|
26
|
+
startTimeout?: number;
|
|
27
|
+
idleTimeoutSeconds?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface MobileAppConfig {
|
|
30
|
+
/** A Revyl app; the session installs its newest build. */
|
|
31
|
+
appId?: string;
|
|
32
|
+
/** An exact Revyl build to install. */
|
|
33
|
+
buildId?: string;
|
|
34
|
+
/** A downloadable .apk, .app.zip or .ipa to install. */
|
|
35
|
+
artifactUrl?: string;
|
|
36
|
+
/** An app already on the device to launch instead of installing one, such as a system app. */
|
|
37
|
+
bundleId?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface MobileTargetConfig {
|
|
40
|
+
platform: MobilePlatform;
|
|
41
|
+
app: MobileAppConfig;
|
|
42
|
+
device?: {
|
|
43
|
+
model?: string;
|
|
44
|
+
osVersion?: string;
|
|
45
|
+
};
|
|
46
|
+
startTimeout?: number;
|
|
47
|
+
idleTimeoutSeconds?: number;
|
|
48
|
+
}
|
|
49
|
+
/** A Revyl computer that runs commands and files instead of agent steps. See docs/computers.md. */
|
|
50
|
+
export interface ComputerTargetConfig {
|
|
51
|
+
platform: ComputerPlatform;
|
|
52
|
+
startTimeout?: number;
|
|
53
|
+
}
|
|
54
|
+
export type TargetConfig = WebTargetConfig | MobileTargetConfig | ComputerTargetConfig;
|
|
55
|
+
export interface VerifyConfig {
|
|
56
|
+
/** Named targets that open() takes by name, such as open('web'). */
|
|
57
|
+
targets: Record<string, TargetConfig>;
|
|
58
|
+
/** Milliseconds one instruction or validation may run. Defaults to 180000. */
|
|
59
|
+
stepTimeout?: number;
|
|
60
|
+
}
|
|
61
|
+
type Resolved<T> = T & {
|
|
62
|
+
name: string;
|
|
63
|
+
startTimeout: number;
|
|
64
|
+
};
|
|
65
|
+
export type ResolvedDeviceTarget = Resolved<(WebTargetConfig | MobileTargetConfig) & {
|
|
66
|
+
idleTimeoutSeconds: number;
|
|
67
|
+
}>;
|
|
68
|
+
export type ResolvedComputerTarget = Resolved<ComputerTargetConfig & ({
|
|
69
|
+
platform: 'mac';
|
|
70
|
+
} | {
|
|
71
|
+
platform: 'linux';
|
|
72
|
+
})>;
|
|
73
|
+
export type ResolvedTarget = ResolvedDeviceTarget | ResolvedComputerTarget;
|
|
74
|
+
export interface ResolvedConfig {
|
|
75
|
+
configFile: string;
|
|
76
|
+
targets: Record<string, ResolvedTarget>;
|
|
77
|
+
stepTimeout: number;
|
|
78
|
+
}
|
|
79
|
+
export declare const CONFIG_FILE_NAMES: string[];
|
|
80
|
+
export declare const DEFAULT_STEP_TIMEOUT_MS = 180000;
|
|
81
|
+
export declare function defineConfig(config: VerifyConfig): VerifyConfig;
|
|
82
|
+
/** The nearest verify.config file in the folder or any folder above it. */
|
|
83
|
+
export declare function findConfigFile(startDir: string): string | undefined;
|
|
84
|
+
export declare function loadConfig(cwd: string, explicitPath?: string): Promise<ResolvedConfig>;
|
|
85
|
+
export declare function validateConfig(raw: unknown, configFile: string): ResolvedConfig;
|
|
86
|
+
/** Validates a target passed straight to open(); it is named after its platform. */
|
|
87
|
+
export declare function validateTarget(raw: unknown): ResolvedTarget;
|
|
88
|
+
export {};
|