@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.
- package/CHANGELOG.md +34 -5
- package/README.md +103 -69
- package/dist/api/client.d.ts +6 -4
- package/dist/api/client.js +64 -14
- package/dist/api/types.d.ts +1 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +64 -0
- package/dist/cli/login.d.ts +6 -0
- package/dist/cli/login.js +200 -0
- package/dist/cli/main.js +45 -11
- package/dist/computer-session.d.ts +14 -4
- package/dist/computer-session.js +106 -23
- package/dist/config.d.ts +7 -0
- package/dist/config.js +16 -2
- package/dist/errors.d.ts +6 -1
- package/dist/errors.js +4 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +1 -1
- package/dist/open.d.ts +10 -2
- package/dist/open.js +35 -6
- package/dist/session.d.ts +21 -2
- package/dist/session.js +134 -13
- package/docs/README.md +0 -1
- package/docs/agents.md +2 -2
- package/docs/api.md +65 -3
- package/docs/ci.md +1 -1
- package/docs/computers.md +14 -13
- package/docs/configuration.md +8 -7
- package/docs/end-to-end.md +3 -3
- package/docs/errors.md +27 -2
- package/docs/getting-started.md +33 -15
- package/docs/llms.txt +4 -5
- package/docs/web.md +3 -1
- package/package.json +1 -1
- package/docs/linux-computers-contract.md +0 -53
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
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:
|
|
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
|
|
47
|
+
private parseReport;
|
|
38
48
|
private assertOpen;
|
|
39
49
|
private observationSignal;
|
|
40
50
|
private unavailable;
|
package/dist/computer-session.js
CHANGED
|
@@ -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
|
|
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
|
|
22
|
+
id;
|
|
21
23
|
viewerUrl;
|
|
22
24
|
steps = [];
|
|
23
25
|
lifetime = new AbortController();
|
|
24
26
|
closePromise;
|
|
25
27
|
expiresAtMs;
|
|
26
28
|
screenReadyUntilMs = 0;
|
|
27
|
-
|
|
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.
|
|
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.
|
|
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}
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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 (
|
|
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
|
-
|
|
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 {};
|