@mcoda/shared 0.1.76 → 0.1.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/mswarm/ArtifactSandboxContract.d.ts +98 -0
- package/dist/mswarm/ArtifactSandboxContract.d.ts.map +1 -0
- package/dist/mswarm/ArtifactSandboxContract.js +111 -0
- package/dist/mswarm/CapabilityContract.d.ts +136 -0
- package/dist/mswarm/CapabilityContract.d.ts.map +1 -0
- package/dist/mswarm/CapabilityContract.js +128 -0
- package/dist/mswarm/GenericJobContract.d.ts +136 -0
- package/dist/mswarm/GenericJobContract.d.ts.map +1 -0
- package/dist/mswarm/GenericJobContract.js +1005 -0
- package/dist/mswarm/LifecycleContract.d.ts +122 -0
- package/dist/mswarm/LifecycleContract.d.ts.map +1 -0
- package/dist/mswarm/LifecycleContract.js +68 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
export const MSWARM_GENERIC_JOB_SCHEMA_VERSION = "2026-06-14";
|
|
2
|
+
export const MSWARM_GENERIC_JOB_SCHEMA_VERSIONS = [
|
|
3
|
+
MSWARM_GENERIC_JOB_SCHEMA_VERSION,
|
|
4
|
+
];
|
|
5
|
+
export const MSWARM_KNOWN_JOB_TYPES = [
|
|
6
|
+
"render.blender",
|
|
7
|
+
"cuda.run",
|
|
8
|
+
"ffmpeg.cuda",
|
|
9
|
+
"python.gpu",
|
|
10
|
+
"package.job",
|
|
11
|
+
];
|
|
12
|
+
export const MSWARM_JOB_STATUSES = [
|
|
13
|
+
"queued",
|
|
14
|
+
"running",
|
|
15
|
+
"succeeded",
|
|
16
|
+
"failed",
|
|
17
|
+
"cancelled",
|
|
18
|
+
"expired",
|
|
19
|
+
];
|
|
20
|
+
export const MSWARM_JOB_EVENT_TYPES = [
|
|
21
|
+
"queued",
|
|
22
|
+
"scheduled",
|
|
23
|
+
"started",
|
|
24
|
+
"heartbeat",
|
|
25
|
+
"stdout",
|
|
26
|
+
"stderr",
|
|
27
|
+
"log_truncated",
|
|
28
|
+
"progress",
|
|
29
|
+
"metric",
|
|
30
|
+
"artifact",
|
|
31
|
+
"completed",
|
|
32
|
+
"failed",
|
|
33
|
+
"cancelled",
|
|
34
|
+
];
|
|
35
|
+
export const MSWARM_JOB_TRUST_MODES = ["owner-local", "tenant-owned"];
|
|
36
|
+
export const MSWARM_JOB_NETWORK_POLICIES = ["none", "egress-allowlist"];
|
|
37
|
+
export const MSWARM_ARTIFACT_SCOPES = ["input", "output", "log", "manifest"];
|
|
38
|
+
const KNOWN_JOB_TYPE_SET = new Set(MSWARM_KNOWN_JOB_TYPES);
|
|
39
|
+
const SCHEMA_VERSION_SET = new Set(MSWARM_GENERIC_JOB_SCHEMA_VERSIONS);
|
|
40
|
+
const TRUST_MODE_SET = new Set(MSWARM_JOB_TRUST_MODES);
|
|
41
|
+
const NETWORK_POLICY_SET = new Set(MSWARM_JOB_NETWORK_POLICIES);
|
|
42
|
+
const ARTIFACT_SCOPE_SET = new Set(MSWARM_ARTIFACT_SCOPES);
|
|
43
|
+
const COMMON_REQUEST_KEYS = new Set([
|
|
44
|
+
"schema_version",
|
|
45
|
+
"job_type",
|
|
46
|
+
"idempotency_key",
|
|
47
|
+
"runner_hint",
|
|
48
|
+
"inputs",
|
|
49
|
+
"args",
|
|
50
|
+
"resources",
|
|
51
|
+
"limits",
|
|
52
|
+
"outputs",
|
|
53
|
+
"policy",
|
|
54
|
+
"metadata",
|
|
55
|
+
]);
|
|
56
|
+
const LLM_REQUEST_KEYS = new Set([
|
|
57
|
+
"openai_request",
|
|
58
|
+
"messages",
|
|
59
|
+
"model",
|
|
60
|
+
"agent_slug",
|
|
61
|
+
"adapter",
|
|
62
|
+
"source_agent_slug",
|
|
63
|
+
"execution_runtime",
|
|
64
|
+
]);
|
|
65
|
+
const UNSAFE_ARG_KEYS = new Set([
|
|
66
|
+
"command",
|
|
67
|
+
"cmd",
|
|
68
|
+
"shell",
|
|
69
|
+
"image",
|
|
70
|
+
"runtime",
|
|
71
|
+
"network",
|
|
72
|
+
"mount",
|
|
73
|
+
"mounts",
|
|
74
|
+
"device",
|
|
75
|
+
"devices",
|
|
76
|
+
"privileged",
|
|
77
|
+
"hostNetwork",
|
|
78
|
+
"host_network",
|
|
79
|
+
"volumes",
|
|
80
|
+
"binds",
|
|
81
|
+
]);
|
|
82
|
+
const UNSAFE_METADATA_KEYS = new Set([
|
|
83
|
+
...UNSAFE_ARG_KEYS,
|
|
84
|
+
"allow_raw_command",
|
|
85
|
+
"allowed_images",
|
|
86
|
+
"allowed_package_publishers",
|
|
87
|
+
"host_path",
|
|
88
|
+
"host_paths",
|
|
89
|
+
]);
|
|
90
|
+
export const MSWARM_KNOWN_JOB_ARG_KEYS = {
|
|
91
|
+
"render.blender": [
|
|
92
|
+
"frames",
|
|
93
|
+
"engine",
|
|
94
|
+
"resolution",
|
|
95
|
+
"output_format",
|
|
96
|
+
"camera",
|
|
97
|
+
"scene",
|
|
98
|
+
],
|
|
99
|
+
"cuda.run": ["manifest_path", "profile", "target"],
|
|
100
|
+
"ffmpeg.cuda": ["input", "output", "filter", "codec", "preset"],
|
|
101
|
+
"python.gpu": ["manifest_path", "entrypoint", "profile"],
|
|
102
|
+
"package.job": ["manifest_path", "package_ref", "task", "profile"],
|
|
103
|
+
};
|
|
104
|
+
const JOB_TYPE_ARG_KEYS = {
|
|
105
|
+
"render.blender": new Set(MSWARM_KNOWN_JOB_ARG_KEYS["render.blender"]),
|
|
106
|
+
"cuda.run": new Set(MSWARM_KNOWN_JOB_ARG_KEYS["cuda.run"]),
|
|
107
|
+
"ffmpeg.cuda": new Set(MSWARM_KNOWN_JOB_ARG_KEYS["ffmpeg.cuda"]),
|
|
108
|
+
"python.gpu": new Set(MSWARM_KNOWN_JOB_ARG_KEYS["python.gpu"]),
|
|
109
|
+
"package.job": new Set(MSWARM_KNOWN_JOB_ARG_KEYS["package.job"]),
|
|
110
|
+
};
|
|
111
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
+
const asNonEmptyString = (value) => {
|
|
113
|
+
if (typeof value !== "string")
|
|
114
|
+
return undefined;
|
|
115
|
+
const trimmed = value.trim();
|
|
116
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
117
|
+
};
|
|
118
|
+
const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
119
|
+
const pushIssue = (issues, issue) => {
|
|
120
|
+
issues.push(issue);
|
|
121
|
+
};
|
|
122
|
+
const isPathOrChildPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}.`);
|
|
123
|
+
const SAFE_GENERIC_ARG_IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
|
|
124
|
+
const SAFE_GENERIC_ARG_PATH_SEGMENT = /^[a-zA-Z0-9_.-]+$/;
|
|
125
|
+
const isSafeGenericArgPath = (value) => {
|
|
126
|
+
if (value.includes("\0") || value.includes("\\") || value.startsWith("/") || value.startsWith("//")) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value) || /^[a-zA-Z]:[\\/]/.test(value)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const parts = value.split("/").filter((part) => part.length > 0 && part !== ".");
|
|
133
|
+
return parts.length > 0 && parts.every((part) => part !== ".." && SAFE_GENERIC_ARG_PATH_SEGMENT.test(part));
|
|
134
|
+
};
|
|
135
|
+
const validateRequiredSafeArgPath = (record, key, path, issues) => {
|
|
136
|
+
const value = asNonEmptyString(record[key]);
|
|
137
|
+
if (!value) {
|
|
138
|
+
pushIssue(issues, {
|
|
139
|
+
code: "invalid_args",
|
|
140
|
+
path: `${path}.${key}`,
|
|
141
|
+
message: `${key} must be a non-empty relative path.`,
|
|
142
|
+
value: record[key],
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!isSafeGenericArgPath(value)) {
|
|
147
|
+
pushIssue(issues, {
|
|
148
|
+
code: "unsafe_path",
|
|
149
|
+
path: `${path}.${key}`,
|
|
150
|
+
message: `${key} must be a safe relative path.`,
|
|
151
|
+
value: record[key],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const validateRequiredSafeArgIdentifier = (record, key, path, issues) => {
|
|
156
|
+
const value = asNonEmptyString(record[key]);
|
|
157
|
+
if (!value) {
|
|
158
|
+
pushIssue(issues, {
|
|
159
|
+
code: "invalid_args",
|
|
160
|
+
path: `${path}.${key}`,
|
|
161
|
+
message: `${key} must be a non-empty identifier.`,
|
|
162
|
+
value: record[key],
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!SAFE_GENERIC_ARG_IDENTIFIER.test(value)) {
|
|
167
|
+
pushIssue(issues, {
|
|
168
|
+
code: "invalid_args",
|
|
169
|
+
path: `${path}.${key}`,
|
|
170
|
+
message: `${key} may only contain letters, numbers, dots, underscores, and hyphens.`,
|
|
171
|
+
value: record[key],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const validateStringArray = (value, path, issues) => {
|
|
176
|
+
if (value === undefined)
|
|
177
|
+
return undefined;
|
|
178
|
+
if (!Array.isArray(value)) {
|
|
179
|
+
pushIssue(issues, {
|
|
180
|
+
code: "invalid_request",
|
|
181
|
+
path,
|
|
182
|
+
message: "Expected an array of non-empty strings.",
|
|
183
|
+
value,
|
|
184
|
+
});
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
const strings = [];
|
|
188
|
+
value.forEach((item, index) => {
|
|
189
|
+
const normalized = asNonEmptyString(item);
|
|
190
|
+
if (!normalized) {
|
|
191
|
+
pushIssue(issues, {
|
|
192
|
+
code: "invalid_request",
|
|
193
|
+
path: `${path}.${index}`,
|
|
194
|
+
message: "Expected a non-empty string.",
|
|
195
|
+
value: item,
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
strings.push(normalized);
|
|
200
|
+
});
|
|
201
|
+
return strings;
|
|
202
|
+
};
|
|
203
|
+
const validatePositiveIntegerField = (record, key, path, code, issues) => {
|
|
204
|
+
const value = record[key];
|
|
205
|
+
if (value === undefined)
|
|
206
|
+
return undefined;
|
|
207
|
+
if (!isPositiveInteger(value)) {
|
|
208
|
+
pushIssue(issues, {
|
|
209
|
+
code,
|
|
210
|
+
path: `${path}.${key}`,
|
|
211
|
+
message: "Expected a positive integer.",
|
|
212
|
+
value,
|
|
213
|
+
});
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
return value;
|
|
217
|
+
};
|
|
218
|
+
const validateRegisteredCatalogPolicy = (value, path, issues) => {
|
|
219
|
+
if (!isRecord(value)) {
|
|
220
|
+
pushIssue(issues, {
|
|
221
|
+
code: "invalid_registered_job_catalog",
|
|
222
|
+
path,
|
|
223
|
+
message: "Registered job catalog entries must include a policy object.",
|
|
224
|
+
value,
|
|
225
|
+
});
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const trustMode = asNonEmptyString(value.trust_mode);
|
|
229
|
+
const network = value.network === undefined ? undefined : asNonEmptyString(value.network);
|
|
230
|
+
let ok = true;
|
|
231
|
+
if (!trustMode || !TRUST_MODE_SET.has(trustMode)) {
|
|
232
|
+
pushIssue(issues, {
|
|
233
|
+
code: "invalid_registered_job_catalog",
|
|
234
|
+
path: `${path}.trust_mode`,
|
|
235
|
+
message: "Registered job catalog policy must include a valid trust_mode.",
|
|
236
|
+
value: value.trust_mode,
|
|
237
|
+
});
|
|
238
|
+
ok = false;
|
|
239
|
+
}
|
|
240
|
+
if (network !== undefined && !NETWORK_POLICY_SET.has(network)) {
|
|
241
|
+
pushIssue(issues, {
|
|
242
|
+
code: "invalid_registered_job_catalog",
|
|
243
|
+
path: `${path}.network`,
|
|
244
|
+
message: "Registered job catalog policy network must be none or egress-allowlist.",
|
|
245
|
+
value: value.network,
|
|
246
|
+
});
|
|
247
|
+
ok = false;
|
|
248
|
+
}
|
|
249
|
+
if (value.allow_raw_command !== undefined && value.allow_raw_command !== false) {
|
|
250
|
+
pushIssue(issues, {
|
|
251
|
+
code: "invalid_registered_job_catalog",
|
|
252
|
+
path: `${path}.allow_raw_command`,
|
|
253
|
+
message: "Registered job catalog policy cannot allow raw command execution.",
|
|
254
|
+
value: value.allow_raw_command,
|
|
255
|
+
});
|
|
256
|
+
ok = false;
|
|
257
|
+
}
|
|
258
|
+
return ok;
|
|
259
|
+
};
|
|
260
|
+
const findRegisteredCatalogEntry = (jobType, options, issues) => {
|
|
261
|
+
const catalog = options.registeredJobCatalog ?? [];
|
|
262
|
+
const index = catalog.findIndex((entry) => isRecord(entry) && entry.job_type === jobType);
|
|
263
|
+
if (index < 0) {
|
|
264
|
+
const legacyNameOnly = new Set(options.registeredJobTypes ?? []).has(jobType);
|
|
265
|
+
pushIssue(issues, {
|
|
266
|
+
code: legacyNameOnly ? "invalid_registered_job_catalog" : "unregistered_job_type",
|
|
267
|
+
path: "job_type",
|
|
268
|
+
message: legacyNameOnly
|
|
269
|
+
? "Registered job type names require a catalog entry with args_schema, policy, and runner mapping."
|
|
270
|
+
: "Registered job types must be present in the tenant job catalog before validation accepts them.",
|
|
271
|
+
value: jobType,
|
|
272
|
+
});
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
const entry = catalog[index];
|
|
276
|
+
const path = `registeredJobCatalog.${index}`;
|
|
277
|
+
if (!isRecord(entry)) {
|
|
278
|
+
pushIssue(issues, {
|
|
279
|
+
code: "invalid_registered_job_catalog",
|
|
280
|
+
path,
|
|
281
|
+
message: "Registered job catalog entry must be an object.",
|
|
282
|
+
value: entry,
|
|
283
|
+
});
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
const allowed = new Set(["job_type", "args_schema", "policy", "runner"]);
|
|
287
|
+
for (const [key, fieldValue] of Object.entries(entry)) {
|
|
288
|
+
if (!allowed.has(key)) {
|
|
289
|
+
pushIssue(issues, {
|
|
290
|
+
code: "invalid_registered_job_catalog",
|
|
291
|
+
path: `${path}.${key}`,
|
|
292
|
+
message: "Unknown registered job catalog field.",
|
|
293
|
+
value: fieldValue,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const runner = asNonEmptyString(entry.runner);
|
|
298
|
+
if (!runner) {
|
|
299
|
+
pushIssue(issues, {
|
|
300
|
+
code: "invalid_registered_job_catalog",
|
|
301
|
+
path: `${path}.runner`,
|
|
302
|
+
message: "Registered job catalog entries must include a non-empty runner mapping.",
|
|
303
|
+
value: entry.runner,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (!isRecord(entry.args_schema)) {
|
|
307
|
+
pushIssue(issues, {
|
|
308
|
+
code: "invalid_registered_job_catalog",
|
|
309
|
+
path: `${path}.args_schema`,
|
|
310
|
+
message: "Registered job catalog entries must include an args schema object.",
|
|
311
|
+
value: entry.args_schema,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const policyOk = validateRegisteredCatalogPolicy(entry.policy, `${path}.policy`, issues);
|
|
315
|
+
if (issues.some((issue) => isPathOrChildPath(issue.path, path)) || !runner || !policyOk) {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
job_type: jobType,
|
|
320
|
+
args_schema: entry.args_schema,
|
|
321
|
+
policy: entry.policy,
|
|
322
|
+
runner,
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
export function isMswarmKnownJobType(value) {
|
|
326
|
+
const normalized = asNonEmptyString(value);
|
|
327
|
+
return normalized ? KNOWN_JOB_TYPE_SET.has(normalized) : false;
|
|
328
|
+
}
|
|
329
|
+
export function isMswarmRegisteredJobType(value) {
|
|
330
|
+
const normalized = asNonEmptyString(value);
|
|
331
|
+
return Boolean(normalized &&
|
|
332
|
+
(normalized.startsWith("tenant.") || normalized.startsWith("package.")) &&
|
|
333
|
+
normalized.split(".").every((part) => /^[a-z0-9][a-z0-9-]*$/.test(part)));
|
|
334
|
+
}
|
|
335
|
+
export function isMswarmJobType(value) {
|
|
336
|
+
return isMswarmKnownJobType(value) || isMswarmRegisteredJobType(value);
|
|
337
|
+
}
|
|
338
|
+
const validateSchemaVersion = (value, issues) => {
|
|
339
|
+
const normalized = asNonEmptyString(value);
|
|
340
|
+
if (!normalized || !SCHEMA_VERSION_SET.has(normalized)) {
|
|
341
|
+
pushIssue(issues, {
|
|
342
|
+
code: "invalid_schema_version",
|
|
343
|
+
path: "schema_version",
|
|
344
|
+
message: `Unsupported mswarm generic job schema version; expected ${MSWARM_GENERIC_JOB_SCHEMA_VERSION}.`,
|
|
345
|
+
value,
|
|
346
|
+
});
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
return normalized;
|
|
350
|
+
};
|
|
351
|
+
const validateJobType = (value, options, issues) => {
|
|
352
|
+
const normalized = asNonEmptyString(value);
|
|
353
|
+
if (!normalized || !isMswarmJobType(normalized)) {
|
|
354
|
+
pushIssue(issues, {
|
|
355
|
+
code: "invalid_job_type",
|
|
356
|
+
path: "job_type",
|
|
357
|
+
message: "Job type must be a known type or a registered tenant/package namespace.",
|
|
358
|
+
value,
|
|
359
|
+
});
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
if (!isMswarmKnownJobType(normalized)) {
|
|
363
|
+
const catalogEntry = findRegisteredCatalogEntry(normalized, options, issues);
|
|
364
|
+
if (!catalogEntry) {
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return normalized;
|
|
369
|
+
};
|
|
370
|
+
const validatePolicy = (value, issues) => {
|
|
371
|
+
if (!isRecord(value)) {
|
|
372
|
+
pushIssue(issues, {
|
|
373
|
+
code: "invalid_policy",
|
|
374
|
+
path: "policy",
|
|
375
|
+
message: "Generic job policy is required and must be an object.",
|
|
376
|
+
value,
|
|
377
|
+
});
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
for (const key of Object.keys(value)) {
|
|
381
|
+
if (![
|
|
382
|
+
"trust_mode",
|
|
383
|
+
"network",
|
|
384
|
+
"allow_raw_command",
|
|
385
|
+
"allowed_images",
|
|
386
|
+
"allowed_package_publishers",
|
|
387
|
+
"max_artifact_bytes",
|
|
388
|
+
].includes(key)) {
|
|
389
|
+
pushIssue(issues, {
|
|
390
|
+
code: "unknown_field",
|
|
391
|
+
path: `policy.${key}`,
|
|
392
|
+
message: "Unknown policy field.",
|
|
393
|
+
value: value[key],
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const trustMode = asNonEmptyString(value.trust_mode);
|
|
398
|
+
if (!trustMode || !TRUST_MODE_SET.has(trustMode)) {
|
|
399
|
+
pushIssue(issues, {
|
|
400
|
+
code: "invalid_policy",
|
|
401
|
+
path: "policy.trust_mode",
|
|
402
|
+
message: "Policy trust_mode must be owner-local or tenant-owned.",
|
|
403
|
+
value: value.trust_mode,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const network = value.network === undefined ? "none" : asNonEmptyString(value.network);
|
|
407
|
+
if (!network || !NETWORK_POLICY_SET.has(network)) {
|
|
408
|
+
pushIssue(issues, {
|
|
409
|
+
code: "invalid_policy",
|
|
410
|
+
path: "policy.network",
|
|
411
|
+
message: "Policy network must be none or egress-allowlist.",
|
|
412
|
+
value: value.network,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (value.allow_raw_command !== undefined && value.allow_raw_command !== false) {
|
|
416
|
+
pushIssue(issues, {
|
|
417
|
+
code: "unsafe_field",
|
|
418
|
+
path: "policy.allow_raw_command",
|
|
419
|
+
message: "Raw command execution is not allowed in the generic job contract.",
|
|
420
|
+
value: value.allow_raw_command,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const allowedImages = validateStringArray(value.allowed_images, "policy.allowed_images", issues);
|
|
424
|
+
const allowedPackagePublishers = validateStringArray(value.allowed_package_publishers, "policy.allowed_package_publishers", issues);
|
|
425
|
+
const maxArtifactBytes = validatePositiveIntegerField(value, "max_artifact_bytes", "policy", "invalid_policy", issues);
|
|
426
|
+
if (issues.some((issue) => isPathOrChildPath(issue.path, "policy"))) {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
trust_mode: trustMode,
|
|
431
|
+
network: network,
|
|
432
|
+
...(value.allow_raw_command === false ? { allow_raw_command: false } : {}),
|
|
433
|
+
...(allowedImages ? { allowed_images: allowedImages } : {}),
|
|
434
|
+
...(allowedPackagePublishers ? { allowed_package_publishers: allowedPackagePublishers } : {}),
|
|
435
|
+
...(maxArtifactBytes !== undefined ? { max_artifact_bytes: maxArtifactBytes } : {}),
|
|
436
|
+
};
|
|
437
|
+
};
|
|
438
|
+
const validateLimits = (value, issues) => {
|
|
439
|
+
if (value === undefined)
|
|
440
|
+
return undefined;
|
|
441
|
+
if (!isRecord(value)) {
|
|
442
|
+
pushIssue(issues, {
|
|
443
|
+
code: "invalid_limits",
|
|
444
|
+
path: "limits",
|
|
445
|
+
message: "Limits must be an object.",
|
|
446
|
+
value,
|
|
447
|
+
});
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
const allowed = new Set([
|
|
451
|
+
"timeout_sec",
|
|
452
|
+
"max_stdout_bytes",
|
|
453
|
+
"max_stderr_bytes",
|
|
454
|
+
"max_output_bytes",
|
|
455
|
+
]);
|
|
456
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
457
|
+
if (!allowed.has(key)) {
|
|
458
|
+
pushIssue(issues, {
|
|
459
|
+
code: key === "network" ? "unsafe_field" : "unknown_field",
|
|
460
|
+
path: `limits.${key}`,
|
|
461
|
+
message: key === "network" ? "Network policy belongs under policy.network." : "Unknown limits field.",
|
|
462
|
+
value: fieldValue,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const limits = {};
|
|
467
|
+
for (const key of allowed) {
|
|
468
|
+
const normalized = validatePositiveIntegerField(value, key, "limits", "invalid_limits", issues);
|
|
469
|
+
if (normalized !== undefined) {
|
|
470
|
+
limits[key] = normalized;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return Object.keys(limits).length > 0 ? limits : undefined;
|
|
474
|
+
};
|
|
475
|
+
const validateRelativeSandboxPath = (value, path, issues) => {
|
|
476
|
+
const normalized = asNonEmptyString(value);
|
|
477
|
+
if (!normalized) {
|
|
478
|
+
pushIssue(issues, {
|
|
479
|
+
code: "unsafe_path",
|
|
480
|
+
path,
|
|
481
|
+
message: "Expected a non-empty relative sandbox path.",
|
|
482
|
+
value,
|
|
483
|
+
});
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
const parts = normalized.split(/[\\/]+/);
|
|
487
|
+
if (normalized.startsWith("/") ||
|
|
488
|
+
normalized.startsWith("~") ||
|
|
489
|
+
normalized.includes("\\") ||
|
|
490
|
+
parts.some((part) => part === ".." || part === "")) {
|
|
491
|
+
pushIssue(issues, {
|
|
492
|
+
code: "unsafe_path",
|
|
493
|
+
path,
|
|
494
|
+
message: "Path must be relative and must not escape the job sandbox.",
|
|
495
|
+
value,
|
|
496
|
+
});
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
return normalized;
|
|
500
|
+
};
|
|
501
|
+
const validateArtifactUri = (value, path, options, issues) => {
|
|
502
|
+
const normalized = asNonEmptyString(value);
|
|
503
|
+
if (!normalized) {
|
|
504
|
+
pushIssue(issues, {
|
|
505
|
+
code: "invalid_artifact",
|
|
506
|
+
path,
|
|
507
|
+
message: "Artifact uri is required.",
|
|
508
|
+
value,
|
|
509
|
+
});
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
if (normalized.startsWith("artifact://"))
|
|
513
|
+
return normalized;
|
|
514
|
+
if (options.allowSignedArtifactUrls && normalized.startsWith("https://"))
|
|
515
|
+
return normalized;
|
|
516
|
+
pushIssue(issues, {
|
|
517
|
+
code: "unsafe_artifact_uri",
|
|
518
|
+
path,
|
|
519
|
+
message: "Artifact uri must be artifact:// unless a signed URL flow explicitly allows https://.",
|
|
520
|
+
value,
|
|
521
|
+
});
|
|
522
|
+
return undefined;
|
|
523
|
+
};
|
|
524
|
+
const validateArtifactRef = (value, path, options, issues) => {
|
|
525
|
+
if (!isRecord(value)) {
|
|
526
|
+
pushIssue(issues, {
|
|
527
|
+
code: "invalid_artifact",
|
|
528
|
+
path,
|
|
529
|
+
message: "Artifact reference must be an object.",
|
|
530
|
+
value,
|
|
531
|
+
});
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
const allowed = new Set(["id", "uri", "name", "content_type", "size_bytes", "sha256", "scope"]);
|
|
535
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
536
|
+
if (!allowed.has(key)) {
|
|
537
|
+
pushIssue(issues, {
|
|
538
|
+
code: "unknown_field",
|
|
539
|
+
path: `${path}.${key}`,
|
|
540
|
+
message: "Unknown artifact field.",
|
|
541
|
+
value: fieldValue,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const uri = validateArtifactUri(value.uri, `${path}.uri`, options, issues);
|
|
546
|
+
const artifact = { uri: uri ?? "" };
|
|
547
|
+
for (const key of ["id", "name", "content_type", "sha256"]) {
|
|
548
|
+
if (value[key] !== undefined) {
|
|
549
|
+
const normalized = asNonEmptyString(value[key]);
|
|
550
|
+
if (!normalized) {
|
|
551
|
+
pushIssue(issues, {
|
|
552
|
+
code: "invalid_artifact",
|
|
553
|
+
path: `${path}.${key}`,
|
|
554
|
+
message: "Expected a non-empty string.",
|
|
555
|
+
value: value[key],
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
artifact[key] = normalized;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const sizeBytes = validatePositiveIntegerField(value, "size_bytes", path, "invalid_artifact", issues);
|
|
564
|
+
if (sizeBytes !== undefined)
|
|
565
|
+
artifact.size_bytes = sizeBytes;
|
|
566
|
+
if (value.scope !== undefined) {
|
|
567
|
+
const scope = asNonEmptyString(value.scope);
|
|
568
|
+
if (!scope || !ARTIFACT_SCOPE_SET.has(scope)) {
|
|
569
|
+
pushIssue(issues, {
|
|
570
|
+
code: "invalid_artifact",
|
|
571
|
+
path: `${path}.scope`,
|
|
572
|
+
message: "Invalid artifact scope.",
|
|
573
|
+
value: value.scope,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
artifact.scope = scope;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return uri ? artifact : undefined;
|
|
581
|
+
};
|
|
582
|
+
const validateInputs = (value, options, issues) => {
|
|
583
|
+
if (value === undefined)
|
|
584
|
+
return undefined;
|
|
585
|
+
if (!Array.isArray(value)) {
|
|
586
|
+
pushIssue(issues, {
|
|
587
|
+
code: "invalid_artifact",
|
|
588
|
+
path: "inputs",
|
|
589
|
+
message: "Inputs must be an array.",
|
|
590
|
+
value,
|
|
591
|
+
});
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
const inputs = [];
|
|
595
|
+
value.forEach((item, index) => {
|
|
596
|
+
const path = `inputs.${index}`;
|
|
597
|
+
if (!isRecord(item)) {
|
|
598
|
+
pushIssue(issues, {
|
|
599
|
+
code: "invalid_artifact",
|
|
600
|
+
path,
|
|
601
|
+
message: "Input must be an object.",
|
|
602
|
+
value: item,
|
|
603
|
+
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const allowed = new Set(["name", "artifact", "mount_path", "required"]);
|
|
607
|
+
for (const [key, fieldValue] of Object.entries(item)) {
|
|
608
|
+
if (!allowed.has(key)) {
|
|
609
|
+
pushIssue(issues, {
|
|
610
|
+
code: "unknown_field",
|
|
611
|
+
path: `${path}.${key}`,
|
|
612
|
+
message: "Unknown input field.",
|
|
613
|
+
value: fieldValue,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const name = asNonEmptyString(item.name);
|
|
618
|
+
if (!name) {
|
|
619
|
+
pushIssue(issues, {
|
|
620
|
+
code: "invalid_artifact",
|
|
621
|
+
path: `${path}.name`,
|
|
622
|
+
message: "Input name is required.",
|
|
623
|
+
value: item.name,
|
|
624
|
+
});
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const artifact = validateArtifactRef(item.artifact, `${path}.artifact`, options, issues);
|
|
628
|
+
const mountPath = item.mount_path === undefined
|
|
629
|
+
? undefined
|
|
630
|
+
: validateRelativeSandboxPath(item.mount_path, `${path}.mount_path`, issues);
|
|
631
|
+
if (item.required !== undefined && typeof item.required !== "boolean") {
|
|
632
|
+
pushIssue(issues, {
|
|
633
|
+
code: "invalid_artifact",
|
|
634
|
+
path: `${path}.required`,
|
|
635
|
+
message: "Input required must be a boolean.",
|
|
636
|
+
value: item.required,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (artifact) {
|
|
640
|
+
inputs.push({
|
|
641
|
+
name,
|
|
642
|
+
artifact,
|
|
643
|
+
...(mountPath ? { mount_path: mountPath } : {}),
|
|
644
|
+
...(typeof item.required === "boolean" ? { required: item.required } : {}),
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
return inputs.length > 0 ? inputs : undefined;
|
|
649
|
+
};
|
|
650
|
+
const validateOutputs = (value, issues) => {
|
|
651
|
+
if (value === undefined)
|
|
652
|
+
return undefined;
|
|
653
|
+
if (!Array.isArray(value)) {
|
|
654
|
+
pushIssue(issues, {
|
|
655
|
+
code: "invalid_output",
|
|
656
|
+
path: "outputs",
|
|
657
|
+
message: "Outputs must be an array.",
|
|
658
|
+
value,
|
|
659
|
+
});
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
const outputs = [];
|
|
663
|
+
value.forEach((item, index) => {
|
|
664
|
+
const path = `outputs.${index}`;
|
|
665
|
+
if (!isRecord(item)) {
|
|
666
|
+
pushIssue(issues, {
|
|
667
|
+
code: "invalid_output",
|
|
668
|
+
path,
|
|
669
|
+
message: "Output must be an object.",
|
|
670
|
+
value: item,
|
|
671
|
+
});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const allowed = new Set(["name", "path", "content_type", "required"]);
|
|
675
|
+
for (const [key, fieldValue] of Object.entries(item)) {
|
|
676
|
+
if (!allowed.has(key)) {
|
|
677
|
+
pushIssue(issues, {
|
|
678
|
+
code: "unknown_field",
|
|
679
|
+
path: `${path}.${key}`,
|
|
680
|
+
message: "Unknown output field.",
|
|
681
|
+
value: fieldValue,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const name = asNonEmptyString(item.name);
|
|
686
|
+
if (!name) {
|
|
687
|
+
pushIssue(issues, {
|
|
688
|
+
code: "invalid_output",
|
|
689
|
+
path: `${path}.name`,
|
|
690
|
+
message: "Output name is required.",
|
|
691
|
+
value: item.name,
|
|
692
|
+
});
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const outputPath = validateRelativeSandboxPath(item.path, `${path}.path`, issues);
|
|
696
|
+
const contentType = item.content_type === undefined ? undefined : asNonEmptyString(item.content_type);
|
|
697
|
+
if (item.content_type !== undefined && !contentType) {
|
|
698
|
+
pushIssue(issues, {
|
|
699
|
+
code: "invalid_output",
|
|
700
|
+
path: `${path}.content_type`,
|
|
701
|
+
message: "Output content_type must be a non-empty string.",
|
|
702
|
+
value: item.content_type,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
if (item.required !== undefined && typeof item.required !== "boolean") {
|
|
706
|
+
pushIssue(issues, {
|
|
707
|
+
code: "invalid_output",
|
|
708
|
+
path: `${path}.required`,
|
|
709
|
+
message: "Output required must be a boolean.",
|
|
710
|
+
value: item.required,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
if (outputPath) {
|
|
714
|
+
outputs.push({
|
|
715
|
+
name,
|
|
716
|
+
path: outputPath,
|
|
717
|
+
...(contentType ? { content_type: contentType } : {}),
|
|
718
|
+
...(typeof item.required === "boolean" ? { required: item.required } : {}),
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
return outputs.length > 0 ? outputs : undefined;
|
|
723
|
+
};
|
|
724
|
+
const validateResources = (value, issues) => {
|
|
725
|
+
if (value === undefined)
|
|
726
|
+
return undefined;
|
|
727
|
+
if (!isRecord(value)) {
|
|
728
|
+
pushIssue(issues, {
|
|
729
|
+
code: "invalid_resources",
|
|
730
|
+
path: "resources",
|
|
731
|
+
message: "Resources must be an object.",
|
|
732
|
+
value,
|
|
733
|
+
});
|
|
734
|
+
return undefined;
|
|
735
|
+
}
|
|
736
|
+
const resources = {};
|
|
737
|
+
const allowed = new Set(["gpu", "cpu", "memory_gb", "disk_gb"]);
|
|
738
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
739
|
+
if (!allowed.has(key)) {
|
|
740
|
+
pushIssue(issues, {
|
|
741
|
+
code: "unknown_field",
|
|
742
|
+
path: `resources.${key}`,
|
|
743
|
+
message: "Unknown resources field.",
|
|
744
|
+
value: fieldValue,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const memoryGb = validatePositiveIntegerField(value, "memory_gb", "resources", "invalid_resources", issues);
|
|
749
|
+
const diskGb = validatePositiveIntegerField(value, "disk_gb", "resources", "invalid_resources", issues);
|
|
750
|
+
if (memoryGb !== undefined)
|
|
751
|
+
resources.memory_gb = memoryGb;
|
|
752
|
+
if (diskGb !== undefined)
|
|
753
|
+
resources.disk_gb = diskGb;
|
|
754
|
+
if (value.cpu !== undefined) {
|
|
755
|
+
if (!isRecord(value.cpu)) {
|
|
756
|
+
pushIssue(issues, {
|
|
757
|
+
code: "invalid_resources",
|
|
758
|
+
path: "resources.cpu",
|
|
759
|
+
message: "CPU resources must be an object.",
|
|
760
|
+
value: value.cpu,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
const cores = validatePositiveIntegerField(value.cpu, "cores", "resources.cpu", "invalid_resources", issues);
|
|
765
|
+
if (cores !== undefined)
|
|
766
|
+
resources.cpu = { cores };
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (value.gpu !== undefined) {
|
|
770
|
+
if (!isRecord(value.gpu)) {
|
|
771
|
+
pushIssue(issues, {
|
|
772
|
+
code: "invalid_resources",
|
|
773
|
+
path: "resources.gpu",
|
|
774
|
+
message: "GPU resources must be an object.",
|
|
775
|
+
value: value.gpu,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
const gpu = {};
|
|
780
|
+
for (const [key, fieldValue] of Object.entries(value.gpu)) {
|
|
781
|
+
if (!["count", "min_vram_gb", "vendor", "cuda_min_version", "capabilities"].includes(key)) {
|
|
782
|
+
pushIssue(issues, {
|
|
783
|
+
code: "unknown_field",
|
|
784
|
+
path: `resources.gpu.${key}`,
|
|
785
|
+
message: "Unknown GPU resource field.",
|
|
786
|
+
value: fieldValue,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const count = validatePositiveIntegerField(value.gpu, "count", "resources.gpu", "invalid_resources", issues);
|
|
791
|
+
const minVramGb = validatePositiveIntegerField(value.gpu, "min_vram_gb", "resources.gpu", "invalid_resources", issues);
|
|
792
|
+
const vendor = value.gpu.vendor === undefined ? undefined : asNonEmptyString(value.gpu.vendor);
|
|
793
|
+
const cudaMinVersion = value.gpu.cuda_min_version === undefined
|
|
794
|
+
? undefined
|
|
795
|
+
: asNonEmptyString(value.gpu.cuda_min_version);
|
|
796
|
+
const capabilities = validateStringArray(value.gpu.capabilities, "resources.gpu.capabilities", issues);
|
|
797
|
+
if (count !== undefined)
|
|
798
|
+
gpu.count = count;
|
|
799
|
+
if (minVramGb !== undefined)
|
|
800
|
+
gpu.min_vram_gb = minVramGb;
|
|
801
|
+
if (vendor)
|
|
802
|
+
gpu.vendor = vendor;
|
|
803
|
+
if (value.gpu.vendor !== undefined && !vendor) {
|
|
804
|
+
pushIssue(issues, {
|
|
805
|
+
code: "invalid_resources",
|
|
806
|
+
path: "resources.gpu.vendor",
|
|
807
|
+
message: "GPU vendor must be a non-empty string.",
|
|
808
|
+
value: value.gpu.vendor,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (cudaMinVersion)
|
|
812
|
+
gpu.cuda_min_version = cudaMinVersion;
|
|
813
|
+
if (value.gpu.cuda_min_version !== undefined && !cudaMinVersion) {
|
|
814
|
+
pushIssue(issues, {
|
|
815
|
+
code: "invalid_resources",
|
|
816
|
+
path: "resources.gpu.cuda_min_version",
|
|
817
|
+
message: "CUDA minimum version must be a non-empty string.",
|
|
818
|
+
value: value.gpu.cuda_min_version,
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
if (capabilities)
|
|
822
|
+
gpu.capabilities = capabilities;
|
|
823
|
+
if (Object.keys(gpu).length > 0)
|
|
824
|
+
resources.gpu = gpu;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return Object.keys(resources).length > 0 ? resources : undefined;
|
|
828
|
+
};
|
|
829
|
+
const validateArgs = (value, jobType, issues) => {
|
|
830
|
+
if (value === undefined)
|
|
831
|
+
return undefined;
|
|
832
|
+
if (!isRecord(value)) {
|
|
833
|
+
pushIssue(issues, {
|
|
834
|
+
code: "invalid_args",
|
|
835
|
+
path: "args",
|
|
836
|
+
message: "Args must be an object.",
|
|
837
|
+
value,
|
|
838
|
+
});
|
|
839
|
+
return undefined;
|
|
840
|
+
}
|
|
841
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
842
|
+
if (UNSAFE_ARG_KEYS.has(key)) {
|
|
843
|
+
pushIssue(issues, {
|
|
844
|
+
code: "unsafe_field",
|
|
845
|
+
path: `args.${key}`,
|
|
846
|
+
message: "Unsafe runtime controls are not allowed in generic job args.",
|
|
847
|
+
value: fieldValue,
|
|
848
|
+
});
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (jobType && isMswarmKnownJobType(jobType)) {
|
|
853
|
+
KNOWN_JOB_ARG_VALIDATORS[jobType](value, issues);
|
|
854
|
+
}
|
|
855
|
+
return { ...value };
|
|
856
|
+
};
|
|
857
|
+
const validateKnownJobArgKeys = (jobType, value, issues) => {
|
|
858
|
+
const allowedKeys = JOB_TYPE_ARG_KEYS[jobType];
|
|
859
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
860
|
+
if (UNSAFE_ARG_KEYS.has(key))
|
|
861
|
+
continue;
|
|
862
|
+
if (!allowedKeys.has(key)) {
|
|
863
|
+
pushIssue(issues, {
|
|
864
|
+
code: "unknown_field",
|
|
865
|
+
path: `args.${key}`,
|
|
866
|
+
message: `Unknown args field for ${jobType}.`,
|
|
867
|
+
value: fieldValue,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
const validateRenderBlenderArgs = (value, issues) => {
|
|
873
|
+
validateKnownJobArgKeys("render.blender", value, issues);
|
|
874
|
+
};
|
|
875
|
+
const validateCudaRunArgs = (value, issues) => {
|
|
876
|
+
validateKnownJobArgKeys("cuda.run", value, issues);
|
|
877
|
+
validateRequiredSafeArgPath(value, "manifest_path", "args", issues);
|
|
878
|
+
validateRequiredSafeArgIdentifier(value, "profile", "args", issues);
|
|
879
|
+
validateRequiredSafeArgIdentifier(value, "target", "args", issues);
|
|
880
|
+
};
|
|
881
|
+
const validateFfmpegCudaArgs = (value, issues) => {
|
|
882
|
+
validateKnownJobArgKeys("ffmpeg.cuda", value, issues);
|
|
883
|
+
};
|
|
884
|
+
const validatePythonGpuArgs = (value, issues) => {
|
|
885
|
+
validateKnownJobArgKeys("python.gpu", value, issues);
|
|
886
|
+
};
|
|
887
|
+
const validatePackageJobArgs = (value, issues) => {
|
|
888
|
+
validateKnownJobArgKeys("package.job", value, issues);
|
|
889
|
+
};
|
|
890
|
+
const KNOWN_JOB_ARG_VALIDATORS = {
|
|
891
|
+
"render.blender": validateRenderBlenderArgs,
|
|
892
|
+
"cuda.run": validateCudaRunArgs,
|
|
893
|
+
"ffmpeg.cuda": validateFfmpegCudaArgs,
|
|
894
|
+
"python.gpu": validatePythonGpuArgs,
|
|
895
|
+
"package.job": validatePackageJobArgs,
|
|
896
|
+
};
|
|
897
|
+
const validateMetadata = (value, issues) => {
|
|
898
|
+
if (value === undefined)
|
|
899
|
+
return undefined;
|
|
900
|
+
if (!isRecord(value)) {
|
|
901
|
+
pushIssue(issues, {
|
|
902
|
+
code: "invalid_request",
|
|
903
|
+
path: "metadata",
|
|
904
|
+
message: "Metadata must be an object.",
|
|
905
|
+
value,
|
|
906
|
+
});
|
|
907
|
+
return undefined;
|
|
908
|
+
}
|
|
909
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
910
|
+
if (UNSAFE_METADATA_KEYS.has(key)) {
|
|
911
|
+
pushIssue(issues, {
|
|
912
|
+
code: "unsafe_field",
|
|
913
|
+
path: `metadata.${key}`,
|
|
914
|
+
message: "Metadata cannot override runtime, network, shell, mount, device, or image behavior.",
|
|
915
|
+
value: fieldValue,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return { ...value };
|
|
920
|
+
};
|
|
921
|
+
export function validateMswarmGenericJobRequest(input, options = {}) {
|
|
922
|
+
const issues = [];
|
|
923
|
+
if (!isRecord(input)) {
|
|
924
|
+
return {
|
|
925
|
+
ok: false,
|
|
926
|
+
issues: [
|
|
927
|
+
{
|
|
928
|
+
code: "invalid_request",
|
|
929
|
+
path: "",
|
|
930
|
+
message: "Generic job request must be an object.",
|
|
931
|
+
value: input,
|
|
932
|
+
},
|
|
933
|
+
],
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
for (const [key, value] of Object.entries(input)) {
|
|
937
|
+
if (LLM_REQUEST_KEYS.has(key)) {
|
|
938
|
+
pushIssue(issues, {
|
|
939
|
+
code: "llm_field_not_allowed",
|
|
940
|
+
path: key,
|
|
941
|
+
message: "LLM invocation fields are not part of the generic job contract.",
|
|
942
|
+
value,
|
|
943
|
+
});
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (!COMMON_REQUEST_KEYS.has(key)) {
|
|
947
|
+
pushIssue(issues, {
|
|
948
|
+
code: "unknown_field",
|
|
949
|
+
path: key,
|
|
950
|
+
message: "Unknown generic job request field.",
|
|
951
|
+
value,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const schemaVersion = validateSchemaVersion(input.schema_version, issues);
|
|
956
|
+
const jobType = validateJobType(input.job_type, options, issues);
|
|
957
|
+
const policy = validatePolicy(input.policy, issues);
|
|
958
|
+
const limits = validateLimits(input.limits, issues);
|
|
959
|
+
const inputs = validateInputs(input.inputs, options, issues);
|
|
960
|
+
const outputs = validateOutputs(input.outputs, issues);
|
|
961
|
+
const resources = validateResources(input.resources, issues);
|
|
962
|
+
const args = validateArgs(input.args, jobType, issues);
|
|
963
|
+
const idempotencyKey = input.idempotency_key === undefined ? undefined : asNonEmptyString(input.idempotency_key);
|
|
964
|
+
if (input.idempotency_key !== undefined && !idempotencyKey) {
|
|
965
|
+
pushIssue(issues, {
|
|
966
|
+
code: "invalid_request",
|
|
967
|
+
path: "idempotency_key",
|
|
968
|
+
message: "Idempotency key must be a non-empty string.",
|
|
969
|
+
value: input.idempotency_key,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
const runnerHint = input.runner_hint === undefined ? undefined : asNonEmptyString(input.runner_hint);
|
|
973
|
+
if (input.runner_hint !== undefined && !runnerHint) {
|
|
974
|
+
pushIssue(issues, {
|
|
975
|
+
code: "invalid_request",
|
|
976
|
+
path: "runner_hint",
|
|
977
|
+
message: "Runner hint must be a non-empty string.",
|
|
978
|
+
value: input.runner_hint,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
const metadata = validateMetadata(input.metadata, issues);
|
|
982
|
+
if (issues.length > 0 || !schemaVersion || !jobType || !policy) {
|
|
983
|
+
return { ok: false, issues };
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
ok: true,
|
|
987
|
+
issues: [],
|
|
988
|
+
value: {
|
|
989
|
+
schema_version: schemaVersion,
|
|
990
|
+
job_type: jobType,
|
|
991
|
+
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
|
992
|
+
...(runnerHint ? { runner_hint: runnerHint } : {}),
|
|
993
|
+
...(inputs ? { inputs } : {}),
|
|
994
|
+
...(args ? { args } : {}),
|
|
995
|
+
...(resources ? { resources } : {}),
|
|
996
|
+
...(limits ? { limits } : {}),
|
|
997
|
+
...(outputs ? { outputs } : {}),
|
|
998
|
+
policy,
|
|
999
|
+
...(metadata ? { metadata } : {}),
|
|
1000
|
+
},
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
export function isMswarmGenericJobRequest(input, options = {}) {
|
|
1004
|
+
return validateMswarmGenericJobRequest(input, options).ok;
|
|
1005
|
+
}
|