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 +5 -2
- package/bin/workspec.js +54 -1
- package/custom-validation-runner.js +304 -66
- package/custom-validation-worker.js +48 -0
- package/package.json +4 -2
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
|
|
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 &&
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
console
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
if (result === '__MISSING_FUNCTION__') {
|
|
377
|
+
if (typeof fn !== 'function') {
|
|
343
378
|
problems.push(buildProblem(
|
|
344
|
-
|
|
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:
|
|
384
|
+
{ function: functionName, metric_id: metricPayload.id }
|
|
350
385
|
));
|
|
351
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
373
|
-
const
|
|
492
|
+
const discoveredFunctions = discoverValidationFunctions(sourceCode);
|
|
493
|
+
const candidateCaptureNames = [...new Set(discoveredFunctions)];
|
|
374
494
|
|
|
375
|
-
let
|
|
376
|
-
let exportedLoaded = false;
|
|
495
|
+
let instrumented;
|
|
377
496
|
try {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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",
|