@diegosouzacdv/jev-browser-mcp 0.7.1 → 0.7.2
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/README.md +280 -55
- package/config/ui-testing.json +31 -10
- package/docs/jev-browser-mcp.md +280 -55
- package/mcp_servers/jev-browser-npm/bin/jev-browser-mcp.cjs +4 -1
- package/mcp_servers/jev-browser-npm/src/browser-flow-runs.mjs +57 -0
- package/mcp_servers/jev-browser-npm/src/config.mjs +136 -37
- package/mcp_servers/jev-browser-npm/src/environment-profiles.mjs +241 -0
- package/mcp_servers/jev-browser-npm/src/flow.mjs +650 -201
- package/mcp_servers/jev-browser-npm/src/server.mjs +136 -35
- package/mcp_servers/jev-browser-npm/src/test-integrations.mjs +655 -0
- package/mcp_servers/jev-browser-npm/src/test-suites.mjs +438 -0
- package/mcp_servers/jev-browser-npm/src/upload-staging.mjs +80 -0
- package/package.json +7 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadEnvironmentProfiles } from "./environment-profiles.mjs";
|
|
5
6
|
|
|
6
7
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
7
8
|
const CONFIG_PATH = path.join(PACKAGE_ROOT, "config", "ui-testing.json");
|
|
@@ -12,9 +13,10 @@ const CONFIG_KEYS = {
|
|
|
12
13
|
"harness_browser",
|
|
13
14
|
"computer_browser",
|
|
14
15
|
"playwright_mcp_package",
|
|
15
|
-
"computer_user_data_dir",
|
|
16
|
-
"max_flow_steps",
|
|
17
|
-
"
|
|
16
|
+
"computer_user_data_dir",
|
|
17
|
+
"max_flow_steps",
|
|
18
|
+
"max_flow_steps_env",
|
|
19
|
+
"max_text_entry_chars",
|
|
18
20
|
],
|
|
19
21
|
nodeBrowser: [
|
|
20
22
|
"max_action_timeout_seconds",
|
|
@@ -42,15 +44,35 @@ const CONFIG_KEYS = {
|
|
|
42
44
|
"record_video_width",
|
|
43
45
|
"record_video_height",
|
|
44
46
|
"max_upload_files",
|
|
45
|
-
"max_upload_path_chars",
|
|
46
|
-
"max_upload_file_bytes",
|
|
47
|
-
"
|
|
47
|
+
"max_upload_path_chars",
|
|
48
|
+
"max_upload_file_bytes",
|
|
49
|
+
"max_upload_file_bytes_env",
|
|
50
|
+
"max_upload_total_bytes",
|
|
51
|
+
"max_upload_total_bytes_env",
|
|
52
|
+
"max_background_flow_runs",
|
|
53
|
+
"background_flow_result_ttl_seconds",
|
|
48
54
|
"max_download_files",
|
|
49
55
|
"max_download_file_bytes",
|
|
50
56
|
"max_download_total_bytes",
|
|
51
|
-
"max_download_timeout_seconds",
|
|
52
|
-
"
|
|
53
|
-
"
|
|
57
|
+
"max_download_timeout_seconds",
|
|
58
|
+
"max_environment_profile_file_bytes",
|
|
59
|
+
"max_test_suite_file_bytes",
|
|
60
|
+
"max_test_suite_criteria",
|
|
61
|
+
"max_test_suite_report_bytes",
|
|
62
|
+
"max_database_query_chars",
|
|
63
|
+
"max_database_bind_values",
|
|
64
|
+
"max_external_extract_variables",
|
|
65
|
+
"max_hook_output_bytes",
|
|
66
|
+
"max_database_timeout_ms",
|
|
67
|
+
"max_database_rows",
|
|
68
|
+
"max_hook_timeout_ms",
|
|
69
|
+
"max_hook_argument_chars",
|
|
70
|
+
"max_smtp_captured_messages",
|
|
71
|
+
"upload_root_env",
|
|
72
|
+
"project_root_env",
|
|
73
|
+
"profiles_file_env",
|
|
74
|
+
"upload_staging_directory",
|
|
75
|
+
"artifact_directory",
|
|
54
76
|
"artifact_directory_env",
|
|
55
77
|
"max_frame_snapshots",
|
|
56
78
|
"fast_path_single_plan_default",
|
|
@@ -110,13 +132,21 @@ function requireKeys(value, keys, scope) {
|
|
|
110
132
|
}
|
|
111
133
|
}
|
|
112
134
|
|
|
113
|
-
function positiveNumber(value, name, integer = false) {
|
|
135
|
+
function positiveNumber(value, name, integer = false) {
|
|
114
136
|
const parsed = Number(value);
|
|
115
137
|
if (!Number.isFinite(parsed) || parsed <= 0 || (integer && !Number.isInteger(parsed))) {
|
|
116
138
|
throw new JevBrowserError(`config/ui-testing.json ${name} must be a positive ${integer ? "integer" : "number"}`);
|
|
117
139
|
}
|
|
118
|
-
return parsed;
|
|
119
|
-
}
|
|
140
|
+
return parsed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function configurableLimit(configValue, env, envName, configPath) {
|
|
144
|
+
const variable = environmentName(envName, `${configPath}_env`);
|
|
145
|
+
const value = env[variable]?.trim();
|
|
146
|
+
if (value === undefined || value === "") return positiveNumber(configValue, configPath, true);
|
|
147
|
+
if (!/^\d+$/.test(value)) throw new JevBrowserError(`${variable} must be a positive integer`);
|
|
148
|
+
return positiveNumber(value, variable, true);
|
|
149
|
+
}
|
|
120
150
|
|
|
121
151
|
function expandHomeDirectory(value) {
|
|
122
152
|
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(homedir(), value.slice(2));
|
|
@@ -131,12 +161,25 @@ function requireOutsidePackage(resolved, name) {
|
|
|
131
161
|
return resolved;
|
|
132
162
|
}
|
|
133
163
|
|
|
134
|
-
function configuredDirectory(value, name) {
|
|
135
|
-
if (typeof value !== "string" || !value.trim()) throw new JevBrowserError(`config/ui-testing.json ${name} must be a directory path`);
|
|
136
|
-
const expanded = expandHomeDirectory(value.trim());
|
|
137
|
-
if (!path.isAbsolute(expanded)) throw new JevBrowserError(`config/ui-testing.json ${name} must be absolute or start with ~`);
|
|
138
|
-
return path.resolve(expanded);
|
|
139
|
-
}
|
|
164
|
+
function configuredDirectory(value, name) {
|
|
165
|
+
if (typeof value !== "string" || !value.trim()) throw new JevBrowserError(`config/ui-testing.json ${name} must be a directory path`);
|
|
166
|
+
const expanded = expandHomeDirectory(value.trim());
|
|
167
|
+
if (!path.isAbsolute(expanded)) throw new JevBrowserError(`config/ui-testing.json ${name} must be absolute or start with ~`);
|
|
168
|
+
return path.resolve(expanded);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function projectRelativeDirectory(projectRoot, value, name) {
|
|
172
|
+
if (typeof value !== "string" || !value.trim() || path.isAbsolute(value.trim())
|
|
173
|
+
|| path.win32.isAbsolute(value.trim()) || value.trim().split(/[\\/]+/).includes("..")) {
|
|
174
|
+
throw new JevBrowserError(`config/ui-testing.json ${name} must be a relative directory inside the project`);
|
|
175
|
+
}
|
|
176
|
+
const resolved = path.resolve(projectRoot, value.trim());
|
|
177
|
+
const relative = path.relative(projectRoot, resolved);
|
|
178
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
179
|
+
throw new JevBrowserError(`config/ui-testing.json ${name} must stay inside the project directory`);
|
|
180
|
+
}
|
|
181
|
+
return resolved;
|
|
182
|
+
}
|
|
140
183
|
|
|
141
184
|
function environmentName(value, name) {
|
|
142
185
|
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(value)) {
|
|
@@ -249,10 +292,38 @@ export function loadSettings({ env = process.env, configPath = CONFIG_PATH } = {
|
|
|
249
292
|
), "browser artifact directory");
|
|
250
293
|
const uploadRootEnv = environmentName(browserOptions.upload_root_env, "jev_browser_mcp.browser.upload_root_env");
|
|
251
294
|
const configuredUploadRoot = env[uploadRootEnv]?.trim();
|
|
252
|
-
const uploadRoot = configuredUploadRoot
|
|
253
|
-
? configuredDirectory(configuredUploadRoot, `environment ${uploadRootEnv}`)
|
|
254
|
-
: null;
|
|
255
|
-
const
|
|
295
|
+
const uploadRoot = configuredUploadRoot
|
|
296
|
+
? configuredDirectory(configuredUploadRoot, `environment ${uploadRootEnv}`)
|
|
297
|
+
: null;
|
|
298
|
+
const projectRootEnv = environmentName(browserOptions.project_root_env, "jev_browser_mcp.browser.project_root_env");
|
|
299
|
+
const projectRoot = configuredDirectory(env[projectRootEnv]?.trim() || process.cwd(), `environment ${projectRootEnv}`);
|
|
300
|
+
const profilesFileEnv = environmentName(browserOptions.profiles_file_env, "jev_browser_mcp.browser.profiles_file_env");
|
|
301
|
+
const configuredProfilesFile = env[profilesFileEnv]?.trim() || path.join(projectRoot, ".jev-browser", "profiles.json");
|
|
302
|
+
const profilesFile = configuredDirectory(configuredProfilesFile, `environment ${profilesFileEnv}`);
|
|
303
|
+
let environmentProfiles;
|
|
304
|
+
try {
|
|
305
|
+
environmentProfiles = loadEnvironmentProfiles({
|
|
306
|
+
projectRoot,
|
|
307
|
+
filePath: profilesFile,
|
|
308
|
+
limits: {
|
|
309
|
+
maxFileBytes: positiveNumber(browserOptions.max_environment_profile_file_bytes, "jev_browser_mcp.browser.max_environment_profile_file_bytes", true),
|
|
310
|
+
maxDatabaseTimeoutMs: positiveNumber(browserOptions.max_database_timeout_ms, "jev_browser_mcp.browser.max_database_timeout_ms", true),
|
|
311
|
+
maxDatabaseRows: positiveNumber(browserOptions.max_database_rows, "jev_browser_mcp.browser.max_database_rows", true),
|
|
312
|
+
maxHookTimeoutMs: positiveNumber(browserOptions.max_hook_timeout_ms, "jev_browser_mcp.browser.max_hook_timeout_ms", true),
|
|
313
|
+
maxHookArgumentChars: positiveNumber(browserOptions.max_hook_argument_chars, "jev_browser_mcp.browser.max_hook_argument_chars", true),
|
|
314
|
+
maxSmtpCapturedMessages: positiveNumber(browserOptions.max_smtp_captured_messages, "jev_browser_mcp.browser.max_smtp_captured_messages", true),
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
} catch (error) {
|
|
318
|
+
throw new JevBrowserError(error?.message || "Jev Browser environment profile file is invalid");
|
|
319
|
+
}
|
|
320
|
+
const uploadStagingBaseDirectory = projectRelativeDirectory(
|
|
321
|
+
projectRoot,
|
|
322
|
+
browserOptions.upload_staging_directory,
|
|
323
|
+
"jev_browser_mcp.browser.upload_staging_directory",
|
|
324
|
+
);
|
|
325
|
+
const uploadStagingDirectory = path.join(uploadStagingBaseDirectory, `mcp-${process.pid}-${sessionId}`);
|
|
326
|
+
const snapshotScope = browserOptions.snapshot_scope_default;
|
|
256
327
|
if (!new Set(["body", "main", "dialog"]).has(snapshotScope)) {
|
|
257
328
|
throw new JevBrowserError("config/ui-testing.json jev_browser_mcp.browser.snapshot_scope_default must be body, main, or dialog");
|
|
258
329
|
}
|
|
@@ -301,18 +372,25 @@ export function loadSettings({ env = process.env, configPath = CONFIG_PATH } = {
|
|
|
301
372
|
browserOptions.post_step_ready_timeout_seconds_default,
|
|
302
373
|
"jev_browser_mcp.browser.post_step_ready_timeout_seconds_default",
|
|
303
374
|
);
|
|
304
|
-
if (postStepReadyTimeoutSeconds > maxReadyTimeoutSeconds) {
|
|
305
|
-
throw new JevBrowserError("config/ui-testing.json post-step readiness timeout exceeds its configured maximum");
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
375
|
+
if (postStepReadyTimeoutSeconds > maxReadyTimeoutSeconds) {
|
|
376
|
+
throw new JevBrowserError("config/ui-testing.json post-step readiness timeout exceeds its configured maximum");
|
|
377
|
+
}
|
|
378
|
+
const maxFlowSteps = configurableLimit(document.browser.max_flow_steps, env, document.browser.max_flow_steps_env, "browser.max_flow_steps");
|
|
379
|
+
const maxUploadFileBytes = configurableLimit(browserOptions.max_upload_file_bytes, env, browserOptions.max_upload_file_bytes_env, "jev_browser_mcp.browser.max_upload_file_bytes");
|
|
380
|
+
const maxUploadTotalBytes = configurableLimit(browserOptions.max_upload_total_bytes, env, browserOptions.max_upload_total_bytes_env, "jev_browser_mcp.browser.max_upload_total_bytes");
|
|
381
|
+
if (maxUploadTotalBytes < maxUploadFileBytes) {
|
|
382
|
+
throw new JevBrowserError("JEV_BROWSER_MAX_UPLOAD_TOTAL_BYTES must be at least the per-file upload limit");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return Object.freeze({
|
|
309
386
|
env,
|
|
310
387
|
browser: Object.freeze({
|
|
311
388
|
mode,
|
|
312
389
|
channel,
|
|
313
390
|
profileDir: profileDirectory(profileTemplate, channel, sessionId, env.JEV_BROWSER_PROFILE),
|
|
314
391
|
sessionId,
|
|
315
|
-
maxFlowSteps
|
|
392
|
+
maxFlowSteps,
|
|
393
|
+
maxFlowStepsEnv: environmentName(document.browser.max_flow_steps_env, "browser.max_flow_steps_env"),
|
|
316
394
|
maxTextEntryChars: positiveNumber(document.browser.max_text_entry_chars, "browser.max_text_entry_chars", true),
|
|
317
395
|
actionTimeoutMs: positiveNumber(browserOptions.max_action_timeout_seconds, "jev_browser_mcp.browser.max_action_timeout_seconds") * 1000,
|
|
318
396
|
mutationConfirmationTtlMs: positiveNumber(browserOptions.mutation_confirmation_ttl_seconds, "jev_browser_mcp.browser.mutation_confirmation_ttl_seconds") * 1000,
|
|
@@ -322,7 +400,20 @@ export function loadSettings({ env = process.env, configPath = CONFIG_PATH } = {
|
|
|
322
400
|
maxStepTimeoutSeconds: positiveNumber(browserOptions.max_step_timeout_seconds, "jev_browser_mcp.browser.max_step_timeout_seconds"),
|
|
323
401
|
keyDelayMs: positiveNumber(browserOptions.key_delay_ms_default, "jev_browser_mcp.browser.key_delay_ms_default", true),
|
|
324
402
|
visibilityPollMs: positiveNumber(browserOptions.visibility_poll_interval_ms_default, "jev_browser_mcp.browser.visibility_poll_interval_ms_default", true),
|
|
325
|
-
maxNetworkErrorBodyBytes: positiveNumber(browserOptions.max_network_error_body_bytes, "jev_browser_mcp.browser.max_network_error_body_bytes", true),
|
|
403
|
+
maxNetworkErrorBodyBytes: positiveNumber(browserOptions.max_network_error_body_bytes, "jev_browser_mcp.browser.max_network_error_body_bytes", true),
|
|
404
|
+
maxEnvironmentProfileFileBytes: positiveNumber(browserOptions.max_environment_profile_file_bytes, "jev_browser_mcp.browser.max_environment_profile_file_bytes", true),
|
|
405
|
+
maxTestSuiteFileBytes: positiveNumber(browserOptions.max_test_suite_file_bytes, "jev_browser_mcp.browser.max_test_suite_file_bytes", true),
|
|
406
|
+
maxTestSuiteCriteria: positiveNumber(browserOptions.max_test_suite_criteria, "jev_browser_mcp.browser.max_test_suite_criteria", true),
|
|
407
|
+
maxTestSuiteReportBytes: positiveNumber(browserOptions.max_test_suite_report_bytes, "jev_browser_mcp.browser.max_test_suite_report_bytes", true),
|
|
408
|
+
maxDatabaseQueryChars: positiveNumber(browserOptions.max_database_query_chars, "jev_browser_mcp.browser.max_database_query_chars", true),
|
|
409
|
+
maxDatabaseBindValues: positiveNumber(browserOptions.max_database_bind_values, "jev_browser_mcp.browser.max_database_bind_values", true),
|
|
410
|
+
maxExternalExtractVariables: positiveNumber(browserOptions.max_external_extract_variables, "jev_browser_mcp.browser.max_external_extract_variables", true),
|
|
411
|
+
maxHookOutputBytes: positiveNumber(browserOptions.max_hook_output_bytes, "jev_browser_mcp.browser.max_hook_output_bytes", true),
|
|
412
|
+
maxDatabaseTimeoutMs: positiveNumber(browserOptions.max_database_timeout_ms, "jev_browser_mcp.browser.max_database_timeout_ms", true),
|
|
413
|
+
maxDatabaseRows: positiveNumber(browserOptions.max_database_rows, "jev_browser_mcp.browser.max_database_rows", true),
|
|
414
|
+
maxHookTimeoutMs: positiveNumber(browserOptions.max_hook_timeout_ms, "jev_browser_mcp.browser.max_hook_timeout_ms", true),
|
|
415
|
+
maxHookArgumentChars: positiveNumber(browserOptions.max_hook_argument_chars, "jev_browser_mcp.browser.max_hook_argument_chars", true),
|
|
416
|
+
maxSmtpCapturedMessages: positiveNumber(browserOptions.max_smtp_captured_messages, "jev_browser_mcp.browser.max_smtp_captured_messages", true),
|
|
326
417
|
maxNetworkErrorMessageChars: positiveNumber(browserOptions.max_network_error_message_chars, "jev_browser_mcp.browser.max_network_error_message_chars", true),
|
|
327
418
|
maxNetworkHistoryEvents: positiveNumber(browserOptions.max_network_history_events, "jev_browser_mcp.browser.max_network_history_events", true),
|
|
328
419
|
maxToolResponseBytes: positiveNumber(browserOptions.max_tool_response_bytes, "jev_browser_mcp.browser.max_tool_response_bytes", true),
|
|
@@ -335,14 +426,22 @@ export function loadSettings({ env = process.env, configPath = CONFIG_PATH } = {
|
|
|
335
426
|
}),
|
|
336
427
|
maxUploadFiles: positiveNumber(browserOptions.max_upload_files, "jev_browser_mcp.browser.max_upload_files", true),
|
|
337
428
|
maxUploadPathChars: positiveNumber(browserOptions.max_upload_path_chars, "jev_browser_mcp.browser.max_upload_path_chars", true),
|
|
338
|
-
maxUploadFileBytes
|
|
339
|
-
|
|
429
|
+
maxUploadFileBytes,
|
|
430
|
+
maxUploadFileBytesEnv: environmentName(browserOptions.max_upload_file_bytes_env, "jev_browser_mcp.browser.max_upload_file_bytes_env"),
|
|
431
|
+
maxUploadTotalBytes,
|
|
432
|
+
maxUploadTotalBytesEnv: environmentName(browserOptions.max_upload_total_bytes_env, "jev_browser_mcp.browser.max_upload_total_bytes_env"),
|
|
433
|
+
maxBackgroundFlowRuns: positiveNumber(browserOptions.max_background_flow_runs, "jev_browser_mcp.browser.max_background_flow_runs", true),
|
|
434
|
+
backgroundFlowResultTtlMs: positiveNumber(browserOptions.background_flow_result_ttl_seconds, "jev_browser_mcp.browser.background_flow_result_ttl_seconds", true) * 1000,
|
|
340
435
|
maxDownloadFiles: positiveNumber(browserOptions.max_download_files, "jev_browser_mcp.browser.max_download_files", true),
|
|
341
436
|
maxDownloadFileBytes: positiveNumber(browserOptions.max_download_file_bytes, "jev_browser_mcp.browser.max_download_file_bytes", true),
|
|
342
437
|
maxDownloadTotalBytes: positiveNumber(browserOptions.max_download_total_bytes, "jev_browser_mcp.browser.max_download_total_bytes", true),
|
|
343
|
-
downloadTimeoutMs: positiveNumber(browserOptions.max_download_timeout_seconds, "jev_browser_mcp.browser.max_download_timeout_seconds") * 1000,
|
|
344
|
-
uploadRoot,
|
|
345
|
-
|
|
438
|
+
downloadTimeoutMs: positiveNumber(browserOptions.max_download_timeout_seconds, "jev_browser_mcp.browser.max_download_timeout_seconds") * 1000,
|
|
439
|
+
uploadRoot,
|
|
440
|
+
projectRoot,
|
|
441
|
+
environmentProfiles,
|
|
442
|
+
uploadStagingDirectory,
|
|
443
|
+
managedUploadStaging: true,
|
|
444
|
+
artifactDirectory,
|
|
346
445
|
maxFrameSnapshots: positiveNumber(browserOptions.max_frame_snapshots, "jev_browser_mcp.browser.max_frame_snapshots", true),
|
|
347
446
|
defaults: Object.freeze({
|
|
348
447
|
fastPath: booleanValue(browserOptions.fast_path_single_plan_default, "jev_browser_mcp.browser.fast_path_single_plan_default"),
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const PROFILE_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,47}$/;
|
|
5
|
+
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
6
|
+
const HOOK_ARG_NAME = /^[A-Za-z][A-Za-z0-9_]{0,31}$/;
|
|
7
|
+
|
|
8
|
+
function object(value, name) {
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function keys(value, required, optional, name) {
|
|
14
|
+
object(value, name);
|
|
15
|
+
const allowed = new Set([...required, ...optional]);
|
|
16
|
+
const missing = required.filter((key) => !Object.hasOwn(value, key));
|
|
17
|
+
const extra = Object.keys(value).filter((key) => !allowed.has(key));
|
|
18
|
+
if (missing.length || extra.length) {
|
|
19
|
+
throw new Error(`${name} has invalid fields${missing.length ? `; missing: ${missing.join(", ")}` : ""}${extra.length ? `; unsupported: ${extra.join(", ")}` : ""}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function envRef(value, name) {
|
|
24
|
+
if (typeof value !== "string" || !ENV_NAME.test(value)) throw new Error(`${name} must name an environment variable`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function string(value, name, max = 512) {
|
|
29
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || value.includes("\0")) throw new Error(`${name} must be a non-empty string of at most ${max} characters`);
|
|
30
|
+
return value.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function positiveInteger(value, name, max) {
|
|
34
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > max) throw new Error(`${name} must be a positive integer no greater than ${max}`);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function projectPath(projectRoot, value, name, { absolute = false } = {}) {
|
|
39
|
+
const raw = string(value, name, 2048);
|
|
40
|
+
if (absolute && !path.isAbsolute(raw)) throw new Error(`${name} must be absolute`);
|
|
41
|
+
if (!absolute && (path.isAbsolute(raw) || path.win32.isAbsolute(raw) || raw.split(/[\\/]+/).includes(".."))) {
|
|
42
|
+
throw new Error(`${name} must be a safe relative path inside the project`);
|
|
43
|
+
}
|
|
44
|
+
const resolved = path.resolve(projectRoot, raw);
|
|
45
|
+
const relative = path.relative(projectRoot, resolved);
|
|
46
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`${name} must stay inside the project`);
|
|
47
|
+
return resolved;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeOrigins(value, name) {
|
|
51
|
+
if (!Array.isArray(value) || value.length > 32) throw new Error(`${name} must be an array with at most 32 origins`);
|
|
52
|
+
const origins = value.map((candidate) => {
|
|
53
|
+
let parsed;
|
|
54
|
+
try { parsed = new URL(string(candidate, name, 512)); } catch { throw new Error(`${name} contains an invalid origin`); }
|
|
55
|
+
if (!new Set(["http:", "https:"]).has(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
56
|
+
throw new Error(`${name} entries must be bare http or https origins`);
|
|
57
|
+
}
|
|
58
|
+
return parsed.origin;
|
|
59
|
+
});
|
|
60
|
+
if (new Set(origins).size !== origins.length) throw new Error(`${name} must not repeat origins`);
|
|
61
|
+
return Object.freeze(origins);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateDatabase(value, name, limits) {
|
|
65
|
+
if (value === undefined) return undefined;
|
|
66
|
+
object(value, name);
|
|
67
|
+
if (value.engine === "postgresql") {
|
|
68
|
+
keys(value, ["engine", "connection_env", "timeout_ms", "max_rows"], [], name);
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
engine: value.engine,
|
|
71
|
+
connectionEnv: envRef(value.connection_env, `${name}.connection_env`),
|
|
72
|
+
timeoutMs: positiveInteger(value.timeout_ms, `${name}.timeout_ms`, limits.maxDatabaseTimeoutMs),
|
|
73
|
+
maxRows: positiveInteger(value.max_rows, `${name}.max_rows`, limits.maxDatabaseRows),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (value.engine === "oracle") {
|
|
77
|
+
keys(value, ["engine", "connect_env", "user_env", "password_env", "timeout_ms", "max_rows"], [], name);
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
engine: value.engine,
|
|
80
|
+
connectEnv: envRef(value.connect_env, `${name}.connect_env`),
|
|
81
|
+
userEnv: envRef(value.user_env, `${name}.user_env`),
|
|
82
|
+
passwordEnv: envRef(value.password_env, `${name}.password_env`),
|
|
83
|
+
timeoutMs: positiveInteger(value.timeout_ms, `${name}.timeout_ms`, limits.maxDatabaseTimeoutMs),
|
|
84
|
+
maxRows: positiveInteger(value.max_rows, `${name}.max_rows`, limits.maxDatabaseRows),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`${name}.engine must be postgresql or oracle`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateRemote(value, name) {
|
|
91
|
+
keys(value, ["protocol", "host", "username_env", "password_env", "root", "port"], ["host_fingerprint_env"], name);
|
|
92
|
+
const protocol = value.protocol;
|
|
93
|
+
if (!new Set(["ftp", "ftps", "sftp"]).has(protocol)) throw new Error(`${name}.protocol must be ftp, ftps, or sftp`);
|
|
94
|
+
const host = string(value.host, `${name}.host`, 253);
|
|
95
|
+
if (/[\s/@?#]/.test(host)) throw new Error(`${name}.host must be a hostname or IP address`);
|
|
96
|
+
const root = string(value.root, `${name}.root`, 1024);
|
|
97
|
+
if (/[\u0000-\u001f\u007f]/u.test(root) || !root.startsWith("/") || root.split("/").includes("..")) {
|
|
98
|
+
throw new Error(`${name}.root must be an absolute remote directory without traversal or control characters`);
|
|
99
|
+
}
|
|
100
|
+
const port = value.port;
|
|
101
|
+
positiveInteger(port, `${name}.port`, 65535);
|
|
102
|
+
const hostFingerprintEnv = value.host_fingerprint_env === undefined ? undefined : envRef(value.host_fingerprint_env, `${name}.host_fingerprint_env`);
|
|
103
|
+
if (protocol === "sftp" && !hostFingerprintEnv) throw new Error(`${name}.host_fingerprint_env is required for SFTP host verification`);
|
|
104
|
+
if (protocol !== "sftp" && hostFingerprintEnv) throw new Error(`${name}.host_fingerprint_env is only valid for SFTP`);
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
protocol, host, port, root,
|
|
107
|
+
usernameEnv: envRef(value.username_env, `${name}.username_env`),
|
|
108
|
+
passwordEnv: envRef(value.password_env, `${name}.password_env`),
|
|
109
|
+
hostFingerprintEnv,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateHook(value, name, projectRoot, limits) {
|
|
114
|
+
keys(value, ["script", "arguments", "timeout_ms"], [], name);
|
|
115
|
+
if (!value.arguments || typeof value.arguments !== "object" || Array.isArray(value.arguments)) throw new Error(`${name}.arguments must be an object schema`);
|
|
116
|
+
const argumentsSchema = {};
|
|
117
|
+
for (const [argument, descriptor] of Object.entries(value.arguments)) {
|
|
118
|
+
if (!HOOK_ARG_NAME.test(argument)) throw new Error(`${name} contains an invalid hook argument name`);
|
|
119
|
+
keys(descriptor, ["type", "required", "max_length"], [], `${name}.arguments.${argument}`);
|
|
120
|
+
if (!new Set(["string", "integer", "boolean"]).has(descriptor.type)) throw new Error(`${name}.arguments.${argument}.type must be string, integer, or boolean`);
|
|
121
|
+
if (descriptor.required !== undefined && typeof descriptor.required !== "boolean") throw new Error(`${name}.arguments.${argument}.required must be a boolean`);
|
|
122
|
+
argumentsSchema[argument] = Object.freeze({
|
|
123
|
+
type: descriptor.type,
|
|
124
|
+
required: descriptor.required,
|
|
125
|
+
maxLength: positiveInteger(descriptor.max_length, `${name}.arguments.${argument}.max_length`, limits.maxHookArgumentChars),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return Object.freeze({
|
|
129
|
+
script: projectPath(projectRoot, value.script, `${name}.script`),
|
|
130
|
+
arguments: Object.freeze(argumentsSchema),
|
|
131
|
+
timeoutMs: positiveInteger(value.timeout_ms, `${name}.timeout_ms`, limits.maxHookTimeoutMs),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateProfile(profileId, raw, projectRoot, limits) {
|
|
136
|
+
keys(raw, ["environment"], ["application_origins", "database", "log_sources", "smtp_capture", "remotes", "hooks"], `profiles.${profileId}`);
|
|
137
|
+
if (!new Set(["dev", "hml", "prd"]).has(raw.environment)) throw new Error(`profiles.${profileId}.environment must be dev, hml, or prd`);
|
|
138
|
+
const applicationOrigins = normalizeOrigins(raw.application_origins ?? [], `profiles.${profileId}.application_origins`);
|
|
139
|
+
const database = validateDatabase(raw.database, `profiles.${profileId}.database`, limits);
|
|
140
|
+
const logSources = {};
|
|
141
|
+
const rawLogs = raw.log_sources ?? {};
|
|
142
|
+
object(rawLogs, `profiles.${profileId}.log_sources`);
|
|
143
|
+
for (const [source, file] of Object.entries(rawLogs)) {
|
|
144
|
+
if (!PROFILE_NAME.test(source)) throw new Error(`profiles.${profileId}.log_sources contains an invalid source name`);
|
|
145
|
+
const filePath = typeof file === "string" && path.isAbsolute(file)
|
|
146
|
+
? path.resolve(file)
|
|
147
|
+
: projectPath(projectRoot, file, `profiles.${profileId}.log_sources.${source}`);
|
|
148
|
+
logSources[source] = filePath;
|
|
149
|
+
}
|
|
150
|
+
let smtpCapture;
|
|
151
|
+
if (raw.smtp_capture !== undefined) {
|
|
152
|
+
keys(raw.smtp_capture, ["port", "bind_address", "max_messages"], [], `profiles.${profileId}.smtp_capture`);
|
|
153
|
+
const bindAddress = raw.smtp_capture.bind_address;
|
|
154
|
+
if (!new Set(["127.0.0.1", "::1"]).has(bindAddress)) throw new Error(`profiles.${profileId}.smtp_capture.bind_address must be loopback`);
|
|
155
|
+
if (!Number.isSafeInteger(raw.smtp_capture.port) || raw.smtp_capture.port < 0 || raw.smtp_capture.port > 65535) {
|
|
156
|
+
throw new Error(`profiles.${profileId}.smtp_capture.port must be 0-65535`);
|
|
157
|
+
}
|
|
158
|
+
smtpCapture = Object.freeze({
|
|
159
|
+
bindAddress,
|
|
160
|
+
port: raw.smtp_capture.port,
|
|
161
|
+
maxMessages: positiveInteger(raw.smtp_capture.max_messages, `profiles.${profileId}.smtp_capture.max_messages`, limits.maxSmtpCapturedMessages),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const remotes = {};
|
|
165
|
+
const rawRemotes = raw.remotes ?? {};
|
|
166
|
+
object(rawRemotes, `profiles.${profileId}.remotes`);
|
|
167
|
+
for (const [remoteId, remote] of Object.entries(rawRemotes)) {
|
|
168
|
+
if (!PROFILE_NAME.test(remoteId)) throw new Error(`profiles.${profileId}.remotes contains an invalid remote name`);
|
|
169
|
+
remotes[remoteId] = validateRemote(remote, `profiles.${profileId}.remotes.${remoteId}`);
|
|
170
|
+
}
|
|
171
|
+
if (raw.environment === "prd" && Object.keys(raw.hooks ?? {}).length) throw new Error(`profiles.${profileId} cannot define hooks for PRD`);
|
|
172
|
+
const hooks = {};
|
|
173
|
+
const rawHooks = raw.hooks ?? {};
|
|
174
|
+
object(rawHooks, `profiles.${profileId}.hooks`);
|
|
175
|
+
for (const [hookId, hook] of Object.entries(rawHooks)) {
|
|
176
|
+
if (!PROFILE_NAME.test(hookId)) throw new Error(`profiles.${profileId}.hooks contains an invalid hook name`);
|
|
177
|
+
hooks[hookId] = validateHook(hook, `profiles.${profileId}.hooks.${hookId}`, projectRoot, limits);
|
|
178
|
+
}
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
id: profileId,
|
|
181
|
+
environment: raw.environment,
|
|
182
|
+
applicationOrigins,
|
|
183
|
+
database,
|
|
184
|
+
logSources: Object.freeze(logSources),
|
|
185
|
+
smtpCapture,
|
|
186
|
+
remotes: Object.freeze(remotes),
|
|
187
|
+
hooks: Object.freeze(hooks),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function loadEnvironmentProfiles({ projectRoot, filePath, limits }) {
|
|
192
|
+
const resolvedRoot = realpathSync(projectRoot);
|
|
193
|
+
const resolvedFile = path.resolve(filePath);
|
|
194
|
+
const relative = path.relative(resolvedRoot, resolvedFile);
|
|
195
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
196
|
+
throw new Error("JEV_BROWSER_PROFILES_FILE must stay inside the project directory");
|
|
197
|
+
}
|
|
198
|
+
let rawText;
|
|
199
|
+
try {
|
|
200
|
+
const info = lstatSync(resolvedFile);
|
|
201
|
+
if (info.isSymbolicLink() || !info.isFile() || info.size > limits.maxFileBytes) throw new Error("profile file is not a regular file within the configured size limit");
|
|
202
|
+
const realFile = realpathSync(resolvedFile);
|
|
203
|
+
if (realFile !== resolvedFile || !statSync(realFile).isFile()) throw new Error("profile file must not use symlinks");
|
|
204
|
+
rawText = readFileSync(realFile, "utf8");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error?.code === "ENOENT") return Object.freeze({ filePath: resolvedFile, profiles: Object.freeze({}) });
|
|
207
|
+
throw new Error("Jev Browser environment profile file is invalid or inaccessible");
|
|
208
|
+
}
|
|
209
|
+
let document;
|
|
210
|
+
try { document = JSON.parse(rawText); } catch { throw new Error("Jev Browser environment profile file must be valid JSON"); }
|
|
211
|
+
keys(document, ["version", "profiles"], [], "environment profiles root");
|
|
212
|
+
if (document.version !== 1) throw new Error("environment profiles version must be 1");
|
|
213
|
+
object(document.profiles, "environment profiles");
|
|
214
|
+
const profiles = {};
|
|
215
|
+
for (const [profileId, profile] of Object.entries(document.profiles)) {
|
|
216
|
+
if (!PROFILE_NAME.test(profileId)) throw new Error("environment profiles contains an invalid profile name");
|
|
217
|
+
profiles[profileId] = validateProfile(profileId, profile, resolvedRoot, limits);
|
|
218
|
+
}
|
|
219
|
+
return Object.freeze({ filePath: resolvedFile, profiles: Object.freeze(profiles) });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function requireEnvironmentProfile(settings, profileId, purpose) {
|
|
223
|
+
if (typeof profileId !== "string" || !profileId) throw new Error(`${purpose} requires options.environment_profile`);
|
|
224
|
+
const profile = settings.browser.environmentProfiles.profiles[profileId];
|
|
225
|
+
if (!profile) throw new Error(`environment profile ${profileId} is not configured in ${settings.browser.environmentProfiles.filePath}`);
|
|
226
|
+
return profile;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function isProfileOriginAllowed(profile, value) {
|
|
230
|
+
try { return profile.applicationOrigins.includes(new URL(value).origin); } catch { return false; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function resolveRemotePath(remote, requested) {
|
|
234
|
+
const value = string(requested, "remote path", 1024).replace(/\\/g, "/");
|
|
235
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) throw new Error("remote path must not contain control characters");
|
|
236
|
+
const normalized = path.posix.normalize(path.posix.join(remote.root, value.replace(/^\/+/, "")));
|
|
237
|
+
if (normalized !== remote.root && !normalized.startsWith(`${remote.root.replace(/\/$/, "")}/`)) {
|
|
238
|
+
throw new Error("remote path must stay inside the configured remote root");
|
|
239
|
+
}
|
|
240
|
+
return normalized;
|
|
241
|
+
}
|