workspec 1.1.0 → 1.1.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/README.md CHANGED
@@ -31,14 +31,17 @@ Validate:
31
31
  ```bash
32
32
  workspec validate path/to/file.workspec.json
33
33
  workspec validate path/to/file.workspec.json --json
34
- workspec validate -custom path/to/simulation-validator-custom.js path/to/file.workspec.json
35
- workspec validate path/to/file.workspec.json --custom path/to/custom-validator.js --custom-catalog path/to/metrics-catalog-custom.json
34
+ workspec validate -custom path/to/simulation-validator-custom.js path/to/file.workspec.json -y
35
+ workspec validate path/to/file.workspec.json --custom path/to/custom-validator.js --custom-catalog path/to/metrics-catalog-custom.json -y
36
36
  ```
37
37
 
38
38
  `-custom/--custom` supports:
39
39
  - Metrics Editor-style `validate*` functions in a plain `.js` file
40
40
  - Node-style exports (`module.exports = function (...) { ... }` or `module.exports.validate = ...`)
41
41
 
42
+ Custom validators run user-provided JavaScript and may be dangerous/malicious. The CLI now requires an interactive `Y` confirmation before custom validation executes. Use `-y` / `--yes` to skip the confirmation (required in non-interactive runs like CI).
43
+ Custom validation execution is isolated in a subprocess with a hard timeout, and catalog/discovered entry points are restricted to `validate*` function names.
44
+
42
45
  If `--custom-catalog` is omitted, the CLI auto-loads `metrics-catalog-custom.json` from the custom validator file's folder when present.
43
46
 
44
47
  Migrate (Previous UAW Syntax -> WorkSpec v1.0.0):
package/bin/workspec.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const readline = require('readline');
6
7
 
7
8
  const validator = require(path.join(__dirname, '..', 'workspec-validator.js'));
8
9
  const migrator = require(path.join(__dirname, '..', 'workspec-migrate-v1-to-v2.js'));
@@ -13,7 +14,7 @@ function printHelp(exitCode = 0) {
13
14
  'workspec - WorkSpec v1.1.0 CLI',
14
15
  '',
15
16
  'Usage:',
16
- ' workspec validate <file.workspec.json> [-custom <validator.js>] [--custom-catalog <catalog.json>] [--json]',
17
+ ' workspec validate <file.workspec.json> [-custom <validator.js>] [--custom-catalog <catalog.json>] [--json] [-y]',
17
18
  ' workspec migrate <file.json> --out <output.json> [--schema]',
18
19
  ' workspec format <file.json> [--write] [--out <output.json>]',
19
20
  '',
@@ -26,6 +27,7 @@ function printHelp(exitCode = 0) {
26
27
  ' -custom <path> Run custom validator code (Metrics Editor compatible).',
27
28
  ' --custom <path> Same as -custom.',
28
29
  ' --custom-catalog <path> Optional metrics-catalog JSON for custom metrics.',
30
+ ' -y, --yes Skip custom validation safety confirmation prompt.',
29
31
  ' --json Print machine-readable problems JSON (validate only).',
30
32
  ' --out <path> Output path (migrate/format).',
31
33
  ' --write Write output (format only; defaults to stdout).',
@@ -71,6 +73,10 @@ function parseArgs(argv) {
71
73
  result.flags.json = true;
72
74
  continue;
73
75
  }
76
+ if (arg === '--yes' || arg === '-y') {
77
+ result.flags.yes = true;
78
+ continue;
79
+ }
74
80
  if (arg === '--custom' || arg === '-custom') {
75
81
  result.flags.custom = args.shift() || '';
76
82
  continue;
@@ -119,6 +125,49 @@ function hasErrors(problems) {
119
125
  return problems.some((p) => p && p.severity === 'error');
120
126
  }
121
127
 
128
+ function promptConfirm(message) {
129
+ return new Promise((resolve) => {
130
+ const rl = readline.createInterface({
131
+ input: process.stdin,
132
+ output: process.stdout
133
+ });
134
+ rl.question(message, (answer) => {
135
+ rl.close();
136
+ resolve(String(answer || '').trim());
137
+ });
138
+ });
139
+ }
140
+
141
+ async function confirmCustomValidation(flags) {
142
+ if (!flags.custom || flags.yes) {
143
+ return true;
144
+ }
145
+
146
+ const warning = [
147
+ 'Custom validation runs JavaScript from the provided script.',
148
+ 'Only continue if you trust the author of this custom validation file.',
149
+ 'Continue custom validation? [Y/N] '
150
+ ].join('\n');
151
+
152
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
153
+ process.stderr.write(
154
+ 'Custom validation confirmation is required in interactive mode. ' +
155
+ 'Re-run with -y/--yes to skip confirmation.\n'
156
+ );
157
+ process.exitCode = 2;
158
+ return false;
159
+ }
160
+
161
+ const response = await promptConfirm(warning);
162
+ if (response.toUpperCase() !== 'Y') {
163
+ process.stderr.write('Custom validation cancelled.\n');
164
+ process.exitCode = 2;
165
+ return false;
166
+ }
167
+
168
+ return true;
169
+ }
170
+
122
171
  async function readInput(filePath) {
123
172
  if (!filePath || filePath === '-') {
124
173
  return readStdin();
@@ -145,6 +194,10 @@ async function handleValidate(filePath, flags) {
145
194
  return;
146
195
  }
147
196
 
197
+ if (!(await confirmCustomValidation(flags))) {
198
+ return;
199
+ }
200
+
148
201
  let raw;
149
202
  try {
150
203
  raw = await readInput(filePath);
@@ -2,11 +2,16 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const vm = require('vm');
5
+ const { spawn } = require('child_process');
6
6
 
7
7
  const WORKSPEC_NAMESPACE = 'https://universalautomation.wiki/workspec';
8
8
  const EXECUTION_TIMEOUT_MS = 5000;
9
+ const SUBPROCESS_GRACE_MS = 1000;
10
+ const MAX_SUBPROCESS_OUTPUT_BYTES = 1024 * 1024;
9
11
  const FUNCTION_NAME_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
12
+ const ALLOWED_VALIDATE_FUNCTION_RE = /^validate[A-Za-z0-9_$]*$/;
13
+ const VALIDATOR_EXTENSIONS = new Set(['.js', '.cjs']);
14
+ const CATALOG_EXTENSIONS = new Set(['.json']);
10
15
 
11
16
  function isPlainObject(value) {
12
17
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -131,12 +136,6 @@ function normalizeProblems(value, fallbackMetricId) {
131
136
  return single ? [single] : [];
132
137
  }
133
138
 
134
- function resolvePath(inputPath) {
135
- if (!inputPath) return '';
136
- if (path.isAbsolute(inputPath)) return inputPath;
137
- return path.resolve(process.cwd(), inputPath);
138
- }
139
-
140
139
  function getSimulation(documentValue) {
141
140
  if (!isPlainObject(documentValue)) return {};
142
141
  return isPlainObject(documentValue.simulation) ? documentValue.simulation : documentValue;
@@ -169,7 +168,7 @@ function discoverValidationFunctions(sourceCode) {
169
168
  let match;
170
169
  while ((match = re.exec(sourceCode)) !== null) {
171
170
  const name = safeTrim(match[1]);
172
- if (name && /^validate[A-Za-z0-9_$]*/.test(name)) {
171
+ if (name && ALLOWED_VALIDATE_FUNCTION_RE.test(name)) {
173
172
  names.add(name);
174
173
  }
175
174
  }
@@ -217,10 +216,9 @@ function parseCatalog(catalogRawText, catalogPath) {
217
216
  }
218
217
 
219
218
  function loadMetricsCatalog(customValidatorPath, explicitCatalogPath) {
220
- const resolvedExplicit = resolvePath(explicitCatalogPath);
221
- if (resolvedExplicit) {
222
- const raw = fs.readFileSync(resolvedExplicit, 'utf8');
223
- return parseCatalog(raw, resolvedExplicit);
219
+ if (explicitCatalogPath) {
220
+ const raw = fs.readFileSync(explicitCatalogPath, 'utf8');
221
+ return parseCatalog(raw, explicitCatalogPath);
224
222
  }
225
223
 
226
224
  const sidecarPath = path.join(path.dirname(customValidatorPath), 'metrics-catalog-custom.json');
@@ -286,24 +284,58 @@ async function runExportedValidation(exportedValue, helpers, problems) {
286
284
  return handled;
287
285
  }
288
286
 
289
- function runBrowserStyleValidation(sourceCode, metrics, helpers, customValidatorPath, problems) {
290
- const sandbox = {
291
- simulation: helpers.simulation,
292
- addResult: helpers.addResult,
293
- addProblem: helpers.addProblem,
294
- _timeToMinutes: helpers._timeToMinutes,
295
- console: {
296
- log: (...args) => console.log('[workspec custom]', ...args),
297
- warn: (...args) => console.warn('[workspec custom]', ...args),
298
- error: (...args) => console.error('[workspec custom]', ...args)
299
- },
300
- require: undefined,
301
- process: undefined,
302
- module: undefined,
303
- exports: undefined,
304
- Buffer: undefined
287
+ function buildInstrumentedSource(sourceCode, captureFunctionNames) {
288
+ const lines = [
289
+ '"use strict";',
290
+ 'const __workspecModule = { exports: {} };',
291
+ 'const __workspecExports = __workspecModule.exports;',
292
+ 'const __workspecConsole = { log: () => {}, warn: () => {}, error: () => {} };',
293
+ 'const __workspecRuntime = (function(require, process, Buffer, global, globalThis, module, exports, console) {',
294
+ ' "use strict";'
295
+ ];
296
+
297
+ lines.push(sourceCode);
298
+
299
+ lines.push(' return {');
300
+ lines.push(' exported: module.exports,');
301
+ lines.push(' functions: {');
302
+
303
+ if (captureFunctionNames.length > 0) {
304
+ captureFunctionNames.forEach((functionName, index) => {
305
+ const trailing = (index === captureFunctionNames.length - 1) ? '' : ',';
306
+ lines.push(` ${JSON.stringify(functionName)}: (typeof ${functionName} === "function" ? ${functionName} : undefined)${trailing}`);
307
+ });
308
+ }
309
+
310
+ lines.push(' }');
311
+ lines.push(' };');
312
+ lines.push('})(undefined, undefined, undefined, undefined, undefined, __workspecModule, __workspecExports, __workspecConsole);');
313
+ lines.push('module.exports = {');
314
+ lines.push(' __workspec_exported: __workspecRuntime.exported,');
315
+ lines.push(' __workspec_functions: __workspecRuntime.functions');
316
+ lines.push('};');
317
+
318
+ return lines.join('\n');
319
+ }
320
+
321
+ function loadInstrumentedValidator(sourceCode, captureFunctionNames) {
322
+ const instrumentedSource = buildInstrumentedSource(sourceCode, captureFunctionNames);
323
+ const loader = new Function('module', 'exports', instrumentedSource); // eslint-disable-line no-new-func
324
+ const localModule = { exports: {} };
325
+ loader(localModule, localModule.exports);
326
+
327
+ const payload = isPlainObject(localModule.exports) ? localModule.exports : {};
328
+ const exported = payload.__workspec_exported;
329
+ const functionMap = isPlainObject(payload.__workspec_functions) ? payload.__workspec_functions : {};
330
+
331
+ return {
332
+ exported,
333
+ functionMap
305
334
  };
335
+ }
306
336
 
337
+ async function runAllowlistedValidation(functionMap, metrics, discoveredFunctions, helpers, problems) {
338
+ const allowedFunctions = new Set(discoveredFunctions);
307
339
  const thisArg = {
308
340
  simulation: helpers.simulation,
309
341
  addResult: helpers.addResult,
@@ -311,13 +343,9 @@ function runBrowserStyleValidation(sourceCode, metrics, helpers, customValidator
311
343
  _timeToMinutes: helpers._timeToMinutes
312
344
  };
313
345
 
314
- const context = vm.createContext(sandbox);
315
- const script = new vm.Script(sourceCode, { filename: customValidatorPath });
316
- script.runInContext(context, { timeout: EXECUTION_TIMEOUT_MS });
317
-
318
346
  for (const metric of metrics) {
319
347
  const functionName = safeTrim(metric.functionName);
320
- if (!FUNCTION_NAME_RE.test(functionName)) {
348
+ if (!FUNCTION_NAME_RE.test(functionName) || !ALLOWED_VALIDATE_FUNCTION_RE.test(functionName)) {
321
349
  problems.push(buildProblem(
322
350
  metric.id || 'custom.validation.function_name',
323
351
  'error',
@@ -328,40 +356,132 @@ function runBrowserStyleValidation(sourceCode, metrics, helpers, customValidator
328
356
  continue;
329
357
  }
330
358
 
331
- context.__metric = {
359
+ if (!allowedFunctions.has(functionName)) {
360
+ problems.push(buildProblem(
361
+ metric.id || metricIdFromFunctionName(functionName),
362
+ 'error',
363
+ 'Disallowed Custom Validation Function',
364
+ `Function '${functionName}' is not in the allowlisted validate* entry points.`,
365
+ '/simulation',
366
+ { function: functionName, metric_id: metric.id || metricIdFromFunctionName(functionName) }
367
+ ));
368
+ continue;
369
+ }
370
+
371
+ const fn = functionMap[functionName];
372
+ const metricPayload = {
332
373
  id: metric.id || metricIdFromFunctionName(functionName),
333
374
  params: isPlainObject(metric.params) ? metric.params : {}
334
375
  };
335
- context.__thisArg = thisArg;
336
-
337
- const invocation = new vm.Script(
338
- `(typeof ${functionName} === 'function') ? ${functionName}.call(__thisArg, __metric) : "__MISSING_FUNCTION__"`
339
- );
340
376
 
341
- const result = invocation.runInContext(context, { timeout: EXECUTION_TIMEOUT_MS });
342
- if (result === '__MISSING_FUNCTION__') {
377
+ if (typeof fn !== 'function') {
343
378
  problems.push(buildProblem(
344
- context.__metric.id,
379
+ metricPayload.id,
345
380
  'error',
346
381
  'Missing Custom Validation Function',
347
382
  `Function '${functionName}' was not found in custom validation code.`,
348
383
  '/simulation',
349
- { function: functionName, metric_id: context.__metric.id }
384
+ { function: functionName, metric_id: metricPayload.id }
350
385
  ));
351
- } else {
352
- problems.push(...normalizeProblems(result, context.__metric.id));
386
+ continue;
353
387
  }
388
+
389
+ const result = await fn.call(thisArg, metricPayload);
390
+ problems.push(...normalizeProblems(result, metricPayload.id));
354
391
  }
355
392
  }
356
393
 
357
- async function runCustomValidation(documentValue, options = {}) {
358
- const customValidatorPath = resolvePath(options.customValidatorPath || options.path || options.customPath);
359
- const customCatalogPath = resolvePath(options.customCatalogPath || options.catalogPath);
394
+ function withTrailingSeparator(value) {
395
+ return value.endsWith(path.sep) ? value : `${value}${path.sep}`;
396
+ }
397
+
398
+ function isPathInside(candidate, parent) {
399
+ if (candidate === parent) return true;
400
+ return candidate.startsWith(withTrailingSeparator(parent));
401
+ }
402
+
403
+ function hasRelativeTraversal(inputPath) {
404
+ const normalized = path.normalize(inputPath);
405
+ if (normalized === '..') return true;
406
+ return normalized.startsWith(`..${path.sep}`) || normalized.includes(`${path.sep}..${path.sep}`);
407
+ }
408
+
409
+ function resolveAndValidateFilePath(inputPath, label, allowedExtensions) {
410
+ const trimmed = safeTrim(inputPath);
411
+ if (!trimmed) return '';
412
+
413
+ if (trimmed.includes('\0')) {
414
+ throw new Error(`${label} contains unsupported null bytes.`);
415
+ }
416
+
417
+ const isAbsolute = path.isAbsolute(trimmed);
418
+ if (!isAbsolute && hasRelativeTraversal(trimmed)) {
419
+ throw new Error(`${label} contains path traversal segments: '${trimmed}'.`);
420
+ }
421
+
422
+ const resolvedPath = isAbsolute
423
+ ? path.resolve(trimmed)
424
+ : path.resolve(process.cwd(), trimmed);
425
+
426
+ let stats;
427
+ try {
428
+ stats = fs.statSync(resolvedPath);
429
+ } catch (error) {
430
+ throw new Error(`Failed to read ${label} (${resolvedPath}): ${error.message}`);
431
+ }
432
+
433
+ if (!stats.isFile()) {
434
+ throw new Error(`${label} must reference a file: ${resolvedPath}`);
435
+ }
436
+
437
+ let realPath;
438
+ try {
439
+ realPath = fs.realpathSync(resolvedPath);
440
+ } catch (error) {
441
+ throw new Error(`Failed to resolve ${label} (${resolvedPath}): ${error.message}`);
442
+ }
443
+
444
+ if (!isAbsolute) {
445
+ const cwdReal = fs.realpathSync(process.cwd());
446
+ if (!isPathInside(realPath, cwdReal)) {
447
+ throw new Error(`${label} resolves outside the current working directory: '${trimmed}'.`);
448
+ }
449
+ }
450
+
451
+ const extension = path.extname(realPath).toLowerCase();
452
+ if (!allowedExtensions.has(extension)) {
453
+ throw new Error(`${label} must use one of: ${[...allowedExtensions].join(', ')}.`);
454
+ }
455
+
456
+ return realPath;
457
+ }
458
+
459
+ function resolveExecutionOptions(options = {}) {
460
+ const customValidatorPath = resolveAndValidateFilePath(
461
+ options.customValidatorPath || options.path || options.customPath,
462
+ 'Custom validator path',
463
+ VALIDATOR_EXTENSIONS
464
+ );
465
+
466
+ const customCatalogPath = resolveAndValidateFilePath(
467
+ options.customCatalogPath || options.catalogPath,
468
+ 'Custom catalog path',
469
+ CATALOG_EXTENSIONS
470
+ );
360
471
 
361
472
  if (!customValidatorPath) {
362
473
  throw new Error('Missing custom validator path.');
363
474
  }
364
475
 
476
+ return {
477
+ customValidatorPath,
478
+ customCatalogPath
479
+ };
480
+ }
481
+
482
+ async function runCustomValidationInProcess(documentValue, options = {}) {
483
+ const { customValidatorPath, customCatalogPath } = resolveExecutionOptions(options);
484
+
365
485
  let sourceCode;
366
486
  try {
367
487
  sourceCode = fs.readFileSync(customValidatorPath, 'utf8');
@@ -369,30 +489,27 @@ async function runCustomValidation(documentValue, options = {}) {
369
489
  throw new Error(`Failed to read custom validator file (${customValidatorPath}): ${error.message}`);
370
490
  }
371
491
 
372
- const problems = [];
373
- const helpers = createHelpers(documentValue, problems);
492
+ const discoveredFunctions = discoverValidationFunctions(sourceCode);
493
+ const candidateCaptureNames = [...new Set(discoveredFunctions)];
374
494
 
375
- let exported = null;
376
- let exportedLoaded = false;
495
+ let instrumented;
377
496
  try {
378
- const resolved = require.resolve(customValidatorPath);
379
- delete require.cache[resolved];
380
- exported = require(resolved);
381
- exportedLoaded = true;
382
- } catch (_error) {
383
- exportedLoaded = false;
497
+ instrumented = loadInstrumentedValidator(sourceCode, candidateCaptureNames);
498
+ } catch (error) {
499
+ throw new Error(`Failed to evaluate custom validator (${customValidatorPath}): ${error.message}`);
384
500
  }
385
501
 
386
- if (exportedLoaded) {
387
- const handled = await runExportedValidation(exported, helpers, problems);
388
- if (handled) {
389
- return problems;
390
- }
502
+ const problems = [];
503
+ const helpers = createHelpers(documentValue, problems);
504
+
505
+ const handled = await runExportedValidation(instrumented.exported, helpers, problems);
506
+ if (handled) {
507
+ return problems;
391
508
  }
392
509
 
393
510
  let metrics = loadMetricsCatalog(customValidatorPath, customCatalogPath);
394
511
  if (metrics.length === 0) {
395
- metrics = discoverValidationFunctions(sourceCode).map((functionName) => ({
512
+ metrics = discoveredFunctions.map((functionName) => ({
396
513
  id: metricIdFromFunctionName(functionName),
397
514
  functionName,
398
515
  params: {}
@@ -407,7 +524,13 @@ async function runCustomValidation(documentValue, options = {}) {
407
524
  }
408
525
 
409
526
  try {
410
- runBrowserStyleValidation(sourceCode, metrics, helpers, customValidatorPath, problems);
527
+ await runAllowlistedValidation(
528
+ instrumented.functionMap,
529
+ metrics,
530
+ discoveredFunctions,
531
+ helpers,
532
+ problems
533
+ );
411
534
  } catch (error) {
412
535
  throw new Error(`Custom validation execution failed (${customValidatorPath}): ${error.message}`);
413
536
  }
@@ -415,6 +538,121 @@ async function runCustomValidation(documentValue, options = {}) {
415
538
  return problems;
416
539
  }
417
540
 
541
+ function runCustomValidationSubprocess(payload, timeoutMs) {
542
+ return new Promise((resolve, reject) => {
543
+ const workerPath = path.join(__dirname, 'custom-validation-worker.js');
544
+ const child = spawn(process.execPath, [workerPath], {
545
+ cwd: process.cwd(),
546
+ stdio: ['pipe', 'pipe', 'pipe'],
547
+ env: {
548
+ PATH: process.env.PATH || '',
549
+ NODE_ENV: 'production',
550
+ NODE_OPTIONS: ''
551
+ }
552
+ });
553
+
554
+ let stdout = '';
555
+ let stderr = '';
556
+ let stdoutBytes = 0;
557
+ let stderrBytes = 0;
558
+ let settled = false;
559
+
560
+ function fail(error) {
561
+ if (settled) return;
562
+ settled = true;
563
+ reject(error);
564
+ }
565
+
566
+ const timer = setTimeout(() => {
567
+ if (settled) return;
568
+ settled = true;
569
+ child.kill('SIGKILL');
570
+ reject(new Error(`Custom validation timed out after ${timeoutMs}ms.`));
571
+ }, timeoutMs + SUBPROCESS_GRACE_MS);
572
+
573
+ child.on('error', (error) => {
574
+ clearTimeout(timer);
575
+ fail(new Error(`Failed to start custom validation worker: ${error.message}`));
576
+ });
577
+
578
+ child.stdout.on('data', (chunk) => {
579
+ if (settled) return;
580
+ stdoutBytes += chunk.length;
581
+ if (stdoutBytes > MAX_SUBPROCESS_OUTPUT_BYTES) {
582
+ child.kill('SIGKILL');
583
+ clearTimeout(timer);
584
+ fail(new Error('Custom validation worker output exceeded the allowed limit.'));
585
+ return;
586
+ }
587
+ stdout += chunk.toString('utf8');
588
+ });
589
+
590
+ child.stderr.on('data', (chunk) => {
591
+ if (settled) return;
592
+ stderrBytes += chunk.length;
593
+ if (stderrBytes > MAX_SUBPROCESS_OUTPUT_BYTES) {
594
+ child.kill('SIGKILL');
595
+ clearTimeout(timer);
596
+ fail(new Error('Custom validation worker error output exceeded the allowed limit.'));
597
+ return;
598
+ }
599
+ stderr += chunk.toString('utf8');
600
+ });
601
+
602
+ child.on('close', (code, signal) => {
603
+ clearTimeout(timer);
604
+ if (settled) return;
605
+
606
+ if (signal && signal !== 'SIGTERM') {
607
+ fail(new Error(`Custom validation worker exited with signal ${signal}.`));
608
+ return;
609
+ }
610
+
611
+ let parsed;
612
+ try {
613
+ parsed = JSON.parse(stdout || '{}');
614
+ } catch (error) {
615
+ const details = stderr.trim() || `Invalid worker output: ${error.message}`;
616
+ fail(new Error(`Custom validation worker returned unreadable output. ${details}`));
617
+ return;
618
+ }
619
+
620
+ if (code !== 0 || !parsed.ok) {
621
+ const message = safeTrim(parsed.error) || safeTrim(stderr) || `Worker exited with code ${code}.`;
622
+ fail(new Error(message));
623
+ return;
624
+ }
625
+
626
+ if (!Array.isArray(parsed.problems)) {
627
+ fail(new Error('Custom validation worker returned an invalid payload.'));
628
+ return;
629
+ }
630
+
631
+ settled = true;
632
+ resolve(parsed.problems);
633
+ });
634
+
635
+ child.stdin.on('error', (error) => {
636
+ clearTimeout(timer);
637
+ fail(new Error(`Failed to send input to custom validation worker: ${error.message}`));
638
+ });
639
+
640
+ child.stdin.end(JSON.stringify(payload));
641
+ });
642
+ }
643
+
644
+ async function runCustomValidation(documentValue, options = {}) {
645
+ const executionOptions = resolveExecutionOptions(options);
646
+ return runCustomValidationSubprocess(
647
+ {
648
+ documentValue,
649
+ options: executionOptions
650
+ },
651
+ EXECUTION_TIMEOUT_MS
652
+ );
653
+ }
654
+
418
655
  module.exports = {
419
- runCustomValidation
656
+ runCustomValidation,
657
+ runCustomValidationInProcess
420
658
  };
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { runCustomValidationInProcess } = require('./custom-validation-runner.js');
5
+
6
+ const MAX_INPUT_BYTES = 1024 * 1024;
7
+
8
+ function readStdin() {
9
+ return new Promise((resolve, reject) => {
10
+ let totalBytes = 0;
11
+ let data = '';
12
+
13
+ process.stdin.setEncoding('utf8');
14
+ process.stdin.on('data', (chunk) => {
15
+ totalBytes += chunk.length;
16
+ if (totalBytes > MAX_INPUT_BYTES) {
17
+ reject(new Error('Custom validation payload exceeded the allowed size.'));
18
+ return;
19
+ }
20
+ data += chunk;
21
+ });
22
+
23
+ process.stdin.on('end', () => resolve(data));
24
+ process.stdin.on('error', reject);
25
+ });
26
+ }
27
+
28
+ async function main() {
29
+ try {
30
+ const raw = await readStdin();
31
+ const payload = JSON.parse(raw || '{}');
32
+
33
+ const documentValue = payload?.documentValue;
34
+ const options = payload?.options || {};
35
+
36
+ const problems = await runCustomValidationInProcess(documentValue, options);
37
+ process.stdout.write(JSON.stringify({ ok: true, problems }));
38
+ process.exitCode = 0;
39
+ } catch (error) {
40
+ process.stdout.write(JSON.stringify({
41
+ ok: false,
42
+ error: (error && error.message) ? error.message : 'Unknown custom validation worker error.'
43
+ }));
44
+ process.exitCode = 1;
45
+ }
46
+ }
47
+
48
+ main();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "workspec",
3
3
  "private": false,
4
- "version": "1.1.0",
4
+ "version": "1.1.1",
5
5
  "description": "WorkSpec v1.1.0 validator and CLI (validate/migrate/format + custom validation).",
6
6
  "license": "AGPL-3.0",
7
7
  "main": "index.js",
@@ -11,12 +11,14 @@
11
11
  "scripts": {
12
12
  "check:sync": "node scripts/check-sync.js",
13
13
  "sync:web": "node scripts/sync-web-assets.js",
14
- "test": "node scripts/self-test.js"
14
+ "test:integration": "node scripts/sync-web-assets.js && node scripts/test-playground-integration.js",
15
+ "test": "node scripts/self-test.js && npm run test:integration"
15
16
  },
16
17
  "files": [
17
18
  "bin/",
18
19
  "index.js",
19
20
  "custom-validation-runner.js",
21
+ "custom-validation-worker.js",
20
22
  "workspec-validator.js",
21
23
  "workspec-migrate-v1-to-v2.js",
22
24
  "v2.0.schema.json",