@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.
@@ -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
- "max_text_entry_chars",
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
- "max_upload_total_bytes",
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
- "upload_root_env",
53
- "artifact_directory",
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 snapshotScope = browserOptions.snapshot_scope_default;
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
- return Object.freeze({
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: positiveNumber(document.browser.max_flow_steps, "browser.max_flow_steps", true),
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: positiveNumber(browserOptions.max_upload_file_bytes, "jev_browser_mcp.browser.max_upload_file_bytes", true),
339
- maxUploadTotalBytes: positiveNumber(browserOptions.max_upload_total_bytes, "jev_browser_mcp.browser.max_upload_total_bytes", true),
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
- artifactDirectory,
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
+ }