@awak-app/simy-cli 0.2.3 → 0.3.3
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 +90 -1
- package/package.json +11 -4
- package/src/agent.js +723 -37
- package/src/bounded-local-task-subtask-pool.js +708 -0
- package/src/bounded-local-task-subtasks-contract.js +1189 -0
- package/src/cli-contract.js +42 -3
- package/src/console/app.js +212 -90
- package/src/desktop-executor.js +21 -2
- package/src/durable-local-task-steps-contract.js +1256 -0
- package/src/durable-local-task-worker.js +2607 -0
- package/src/execution-capability-contract.js +116 -0
- package/src/execution-guardrail.js +69 -12
- package/src/index.js +8 -7
- package/src/local-attachments.js +94 -113
- package/src/local-task-artifact-contract.js +266 -0
- package/src/local-task-attachment-store.js +447 -0
- package/src/local-task-file-capabilities.js +738 -0
- package/src/local-task-scenario-packs.js +681 -0
- package/src/local-task.js +1094 -0
- package/src/repository-inventory.js +37 -1
- package/src/shutdown.js +63 -0
- package/src/workspace-context.js +40 -7
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { inflateSync } from "node:zlib";
|
|
5
|
+
|
|
6
|
+
import { PDFDocument } from "pdf-lib";
|
|
7
|
+
import { PNG } from "pngjs";
|
|
8
|
+
|
|
9
|
+
import { validateLocalTaskFile } from "./local-task-file-capabilities.js";
|
|
10
|
+
|
|
11
|
+
export const LOCAL_TASK_SCENARIO_PACK_VERSION = "local_task_scenario_pack.v1";
|
|
12
|
+
|
|
13
|
+
const OOXML_WORD =
|
|
14
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
15
|
+
const OOXML_SHEET =
|
|
16
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
17
|
+
const OOXML_PRESENTATION =
|
|
18
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
19
|
+
const MAX_SCENARIO_PDF_BYTES = 25 * 1024 * 1024;
|
|
20
|
+
const MAX_SCENARIO_PNG_DIMENSION = 16_384;
|
|
21
|
+
const MAX_SCENARIO_PNG_PIXELS = 16_777_216;
|
|
22
|
+
|
|
23
|
+
const PACKS = [
|
|
24
|
+
pack("research", {
|
|
25
|
+
title: "Cited research brief",
|
|
26
|
+
description:
|
|
27
|
+
"Inspect external sources and produce a reusable Markdown report with citations.",
|
|
28
|
+
inputRequirements: [
|
|
29
|
+
requirement("instruction", "instruction", { required: true }),
|
|
30
|
+
],
|
|
31
|
+
sourcePolicy: {
|
|
32
|
+
external_sources: "required",
|
|
33
|
+
citation_policy: "required_for_external_claims",
|
|
34
|
+
},
|
|
35
|
+
expectedOutputs: [
|
|
36
|
+
output("research_report", "Research report", [".md"], ["text/markdown"]),
|
|
37
|
+
],
|
|
38
|
+
acceptanceRules: [
|
|
39
|
+
rule("research_report_present", "research_report", "artifact_present"),
|
|
40
|
+
rule("research_report_type", "research_report", "extension_mime_match"),
|
|
41
|
+
rule("research_report_non_empty", "research_report", "non_empty"),
|
|
42
|
+
rule("research_report_citations", "research_report", "citations_present"),
|
|
43
|
+
],
|
|
44
|
+
}),
|
|
45
|
+
pack("document", {
|
|
46
|
+
title: "Document",
|
|
47
|
+
description: "Create a reusable Word document from the request and optional source files.",
|
|
48
|
+
inputRequirements: [
|
|
49
|
+
requirement("instruction", "instruction", { required: true }),
|
|
50
|
+
requirement("source_files", "attachment", {
|
|
51
|
+
required: false,
|
|
52
|
+
acceptedFileCategories: ["text", "office", "pdf"],
|
|
53
|
+
}),
|
|
54
|
+
],
|
|
55
|
+
sourcePolicy: {
|
|
56
|
+
external_sources: "allowed",
|
|
57
|
+
citation_policy: "cite_provided_sources",
|
|
58
|
+
},
|
|
59
|
+
expectedOutputs: [
|
|
60
|
+
output("document", "Word document", [".docx"], [OOXML_WORD]),
|
|
61
|
+
],
|
|
62
|
+
acceptanceRules: [
|
|
63
|
+
rule("document_present", "document", "artifact_present"),
|
|
64
|
+
rule("document_type", "document", "extension_mime_match"),
|
|
65
|
+
rule("document_non_empty", "document", "non_empty"),
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
pack("spreadsheet", {
|
|
69
|
+
title: "Spreadsheet",
|
|
70
|
+
description: "Transform a supplied table or spreadsheet into a structured Excel workbook.",
|
|
71
|
+
inputRequirements: [
|
|
72
|
+
requirement("instruction", "instruction", { required: true }),
|
|
73
|
+
requirement("source_table", "attachment", {
|
|
74
|
+
required: true,
|
|
75
|
+
acceptedFileCategories: ["spreadsheet", "text"],
|
|
76
|
+
minCount: 1,
|
|
77
|
+
recoveryAction: "choose_supported_file",
|
|
78
|
+
}),
|
|
79
|
+
],
|
|
80
|
+
sourcePolicy: {
|
|
81
|
+
external_sources: "forbidden",
|
|
82
|
+
citation_policy: "not_required",
|
|
83
|
+
},
|
|
84
|
+
expectedOutputs: [
|
|
85
|
+
output("workbook", "Excel workbook", [".xlsx"], [OOXML_SHEET]),
|
|
86
|
+
],
|
|
87
|
+
acceptanceRules: [
|
|
88
|
+
rule("workbook_present", "workbook", "artifact_present"),
|
|
89
|
+
rule("workbook_type", "workbook", "extension_mime_match"),
|
|
90
|
+
rule("workbook_non_empty", "workbook", "non_empty"),
|
|
91
|
+
rule("workbook_structure", "workbook", "structured_workbook"),
|
|
92
|
+
],
|
|
93
|
+
}),
|
|
94
|
+
pack("presentation_pdf", {
|
|
95
|
+
title: "Presentation and PDF",
|
|
96
|
+
description: "Create an editable presentation and its reviewable PDF export.",
|
|
97
|
+
inputRequirements: [
|
|
98
|
+
requirement("instruction", "instruction", { required: true }),
|
|
99
|
+
requirement("source_files", "attachment", {
|
|
100
|
+
required: false,
|
|
101
|
+
acceptedFileCategories: ["text", "office", "pdf", "image"],
|
|
102
|
+
}),
|
|
103
|
+
],
|
|
104
|
+
sourcePolicy: {
|
|
105
|
+
external_sources: "allowed",
|
|
106
|
+
citation_policy: "cite_provided_sources",
|
|
107
|
+
},
|
|
108
|
+
expectedOutputs: [
|
|
109
|
+
output("presentation", "Presentation", [".pptx"], [OOXML_PRESENTATION]),
|
|
110
|
+
output("presentation_pdf", "Presentation PDF", [".pdf"], ["application/pdf"]),
|
|
111
|
+
],
|
|
112
|
+
acceptanceRules: [
|
|
113
|
+
rule("presentation_present", "presentation", "artifact_present"),
|
|
114
|
+
rule("presentation_type", "presentation", "extension_mime_match"),
|
|
115
|
+
rule("presentation_non_empty", "presentation", "non_empty"),
|
|
116
|
+
rule("presentation_pdf_present", "presentation_pdf", "artifact_present"),
|
|
117
|
+
rule("presentation_pdf_type", "presentation_pdf", "extension_mime_match"),
|
|
118
|
+
rule("presentation_pdf_non_empty", "presentation_pdf", "non_empty"),
|
|
119
|
+
rule("presentation_pdf_pages", "presentation_pdf", "page_count", { min: 1 }),
|
|
120
|
+
],
|
|
121
|
+
}),
|
|
122
|
+
pack("image", {
|
|
123
|
+
title: "Image asset",
|
|
124
|
+
description: "Create a reusable PNG image asset.",
|
|
125
|
+
inputRequirements: [
|
|
126
|
+
requirement("instruction", "instruction", { required: true }),
|
|
127
|
+
requirement("reference_image", "attachment", {
|
|
128
|
+
required: false,
|
|
129
|
+
acceptedFileCategories: ["image"],
|
|
130
|
+
}),
|
|
131
|
+
],
|
|
132
|
+
sourcePolicy: {
|
|
133
|
+
external_sources: "forbidden",
|
|
134
|
+
citation_policy: "not_required",
|
|
135
|
+
},
|
|
136
|
+
expectedOutputs: [
|
|
137
|
+
output("image", "PNG image", [".png"], ["image/png"]),
|
|
138
|
+
],
|
|
139
|
+
acceptanceRules: [
|
|
140
|
+
rule("image_present", "image", "artifact_present"),
|
|
141
|
+
rule("image_type", "image", "extension_mime_match"),
|
|
142
|
+
rule("image_non_empty", "image", "non_empty"),
|
|
143
|
+
rule("image_dimensions", "image", "image_dimensions", {
|
|
144
|
+
min_width: 256,
|
|
145
|
+
min_height: 256,
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
148
|
+
}),
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const REGISTRY_WITHOUT_HASH = {
|
|
152
|
+
schema_version: LOCAL_TASK_SCENARIO_PACK_VERSION,
|
|
153
|
+
max_selected_packs: 1,
|
|
154
|
+
free_text_compatible: true,
|
|
155
|
+
packs: PACKS,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const LOCAL_TASK_SCENARIO_PACK_REGISTRY = deepFreeze({
|
|
159
|
+
...REGISTRY_WITHOUT_HASH,
|
|
160
|
+
contract_hash: createHash("sha256")
|
|
161
|
+
.update(JSON.stringify(REGISTRY_WITHOUT_HASH))
|
|
162
|
+
.digest("hex"),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const PACKS_BY_ID = new Map(PACKS.map((value) => [value.id, value]));
|
|
166
|
+
const ALLOWED_SELECTIONS = new Set(["explicit", "inferred"]);
|
|
167
|
+
const PACK_REFERENCE_FIELDS = new Set([
|
|
168
|
+
"schema_version",
|
|
169
|
+
"id",
|
|
170
|
+
"selection",
|
|
171
|
+
"contract_hash",
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
export class LocalTaskScenarioPackError extends Error {
|
|
175
|
+
constructor(message, {
|
|
176
|
+
code = "local_task_scenario_pack_invalid",
|
|
177
|
+
recovery = null,
|
|
178
|
+
outputId = null,
|
|
179
|
+
ruleId = null,
|
|
180
|
+
} = {}) {
|
|
181
|
+
super(message);
|
|
182
|
+
this.name = "LocalTaskScenarioPackError";
|
|
183
|
+
this.code = code;
|
|
184
|
+
this.recovery = recovery;
|
|
185
|
+
this.output_id = outputId;
|
|
186
|
+
this.rule_id = ruleId;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function localTaskScenarioPackMetadata() {
|
|
191
|
+
return structuredClone(LOCAL_TASK_SCENARIO_PACK_REGISTRY);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function normalizeLocalTaskScenarioPack(value) {
|
|
195
|
+
if (value === null || value === undefined) return null;
|
|
196
|
+
if (!isRecord(value) || Object.keys(value).some((key) => !PACK_REFERENCE_FIELDS.has(key))) {
|
|
197
|
+
throw scenarioError("SIMY Web returned an invalid Local Task scenario pack.");
|
|
198
|
+
}
|
|
199
|
+
const id = String(value.id || "");
|
|
200
|
+
if (
|
|
201
|
+
value.schema_version !== LOCAL_TASK_SCENARIO_PACK_VERSION ||
|
|
202
|
+
!PACKS_BY_ID.has(id) ||
|
|
203
|
+
!ALLOWED_SELECTIONS.has(value.selection) ||
|
|
204
|
+
value.contract_hash !== LOCAL_TASK_SCENARIO_PACK_REGISTRY.contract_hash
|
|
205
|
+
) {
|
|
206
|
+
throw scenarioError(
|
|
207
|
+
"Update SIMY CLI before running this Local Task scenario.",
|
|
208
|
+
"unsupported_local_task_scenario_pack",
|
|
209
|
+
"update_cli",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
schema_version: LOCAL_TASK_SCENARIO_PACK_VERSION,
|
|
214
|
+
id,
|
|
215
|
+
selection: value.selection,
|
|
216
|
+
contract_hash: LOCAL_TASK_SCENARIO_PACK_REGISTRY.contract_hash,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function localTaskScenarioPackContract(value) {
|
|
221
|
+
const reference =
|
|
222
|
+
typeof value === "string"
|
|
223
|
+
? { id: value }
|
|
224
|
+
: value;
|
|
225
|
+
const contract = PACKS_BY_ID.get(reference?.id);
|
|
226
|
+
if (!contract) {
|
|
227
|
+
throw scenarioError("The Local Task scenario is not supported.");
|
|
228
|
+
}
|
|
229
|
+
return structuredClone(contract);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function localTaskScenarioExpectedOutputs(value) {
|
|
233
|
+
if (!value) return [];
|
|
234
|
+
return localTaskScenarioPackContract(value).expected_outputs.map((item) => ({
|
|
235
|
+
kind: item.id,
|
|
236
|
+
format: item.extensions.join(" or "),
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function validateLocalTaskScenarioInputs(value, {
|
|
241
|
+
instruction,
|
|
242
|
+
attachments = [],
|
|
243
|
+
} = {}) {
|
|
244
|
+
if (!value) return { categories: [] };
|
|
245
|
+
const contract = localTaskScenarioPackContract(value);
|
|
246
|
+
const normalizedInstruction = String(instruction || "").trim();
|
|
247
|
+
const categories = attachments.map((attachment) =>
|
|
248
|
+
validateLocalTaskFile(attachment, { direction: "input" }).category);
|
|
249
|
+
|
|
250
|
+
for (const requirementValue of contract.input_requirements) {
|
|
251
|
+
if (requirementValue.kind === "instruction" && requirementValue.required) {
|
|
252
|
+
if (!normalizedInstruction) {
|
|
253
|
+
throw scenarioError(
|
|
254
|
+
"Describe what you want SIMY to create.",
|
|
255
|
+
"local_task_scenario_instruction_required",
|
|
256
|
+
"revise_request",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (requirementValue.kind !== "attachment") continue;
|
|
262
|
+
const count = categories.filter((category) =>
|
|
263
|
+
requirementValue.accepted_file_categories.includes(category)).length;
|
|
264
|
+
if (requirementValue.required && count < requirementValue.min_count) {
|
|
265
|
+
throw scenarioError(
|
|
266
|
+
"Attach a supported source file before running this scenario.",
|
|
267
|
+
"local_task_scenario_attachment_required",
|
|
268
|
+
requirementValue.recovery_action,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (attachments.length > 0 && count !== attachments.length) {
|
|
272
|
+
throw scenarioError(
|
|
273
|
+
"Remove files that are not supported by this scenario, then try again.",
|
|
274
|
+
"local_task_scenario_attachment_unsupported",
|
|
275
|
+
requirementValue.recovery_action || "choose_supported_file",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { categories };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function localTaskScenarioInstruction(value) {
|
|
283
|
+
if (!value) return [];
|
|
284
|
+
const contract = localTaskScenarioPackContract(value);
|
|
285
|
+
const outputs = contract.expected_outputs.map((item) =>
|
|
286
|
+
`- ${item.label}: ${item.extensions.join(" or ")} (${item.mime_types.join(" or ")})`);
|
|
287
|
+
const rules = contract.acceptance_rules.map((item) =>
|
|
288
|
+
`- ${acceptanceInstruction(item)}`);
|
|
289
|
+
const sourceInstruction = {
|
|
290
|
+
required:
|
|
291
|
+
"Inspect external sources yourself. Cite only sources you actually inspected, and use Markdown links for factual external claims.",
|
|
292
|
+
allowed:
|
|
293
|
+
"External sources are optional. If you use or rely on supplied sources, cite them in the deliverable.",
|
|
294
|
+
forbidden:
|
|
295
|
+
"Do not use external sources for this scenario; work only from the request and verified attachments.",
|
|
296
|
+
}[contract.source_policy.external_sources];
|
|
297
|
+
return [
|
|
298
|
+
`Scenario pack: ${contract.title} (${contract.id}).`,
|
|
299
|
+
contract.description,
|
|
300
|
+
sourceInstruction,
|
|
301
|
+
"Required scenario outputs:",
|
|
302
|
+
...outputs,
|
|
303
|
+
"Acceptance rules:",
|
|
304
|
+
...rules,
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function validateLocalTaskScenarioArtifacts(value, artifacts, {
|
|
309
|
+
outputsRoot,
|
|
310
|
+
} = {}) {
|
|
311
|
+
if (!value) return { matched_outputs: {}, checks: [] };
|
|
312
|
+
const contract = localTaskScenarioPackContract(value);
|
|
313
|
+
const remaining = [...(Array.isArray(artifacts) ? artifacts : [])];
|
|
314
|
+
const matched = {};
|
|
315
|
+
|
|
316
|
+
for (const expected of contract.expected_outputs) {
|
|
317
|
+
const index = remaining.findIndex((artifact) => {
|
|
318
|
+
const extension = path.extname(String(artifact?.name || "")).toLowerCase();
|
|
319
|
+
return expected.extensions.includes(extension) &&
|
|
320
|
+
expected.mime_types.includes(artifact?.mime_type);
|
|
321
|
+
});
|
|
322
|
+
if (index < 0) {
|
|
323
|
+
throw scenarioError(
|
|
324
|
+
`The ${contract.title} scenario did not produce the required ${expected.label}.`,
|
|
325
|
+
"local_task_scenario_artifact_missing",
|
|
326
|
+
null,
|
|
327
|
+
expected.id,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
matched[expected.id] = remaining.splice(index, 1)[0];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const checks = [];
|
|
334
|
+
for (const acceptance of contract.acceptance_rules) {
|
|
335
|
+
const artifact = matched[acceptance.output_id];
|
|
336
|
+
await validateAcceptanceRule(acceptance, artifact, outputsRoot);
|
|
337
|
+
checks.push({ id: acceptance.id, passed: true });
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
matched_outputs: Object.fromEntries(
|
|
341
|
+
Object.entries(matched).map(([id, artifact]) => [id, artifact.id])),
|
|
342
|
+
checks,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function pack(id, {
|
|
347
|
+
title,
|
|
348
|
+
description,
|
|
349
|
+
inputRequirements,
|
|
350
|
+
sourcePolicy,
|
|
351
|
+
expectedOutputs,
|
|
352
|
+
acceptanceRules,
|
|
353
|
+
}) {
|
|
354
|
+
return {
|
|
355
|
+
id,
|
|
356
|
+
title,
|
|
357
|
+
description,
|
|
358
|
+
input_requirements: inputRequirements,
|
|
359
|
+
source_policy: sourcePolicy,
|
|
360
|
+
expected_outputs: expectedOutputs,
|
|
361
|
+
acceptance_rules: acceptanceRules,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function requirement(id, kind, {
|
|
366
|
+
required,
|
|
367
|
+
acceptedFileCategories = [],
|
|
368
|
+
minCount = required ? 1 : 0,
|
|
369
|
+
recoveryAction = null,
|
|
370
|
+
}) {
|
|
371
|
+
return {
|
|
372
|
+
id,
|
|
373
|
+
kind,
|
|
374
|
+
required,
|
|
375
|
+
accepted_file_categories: acceptedFileCategories,
|
|
376
|
+
min_count: minCount,
|
|
377
|
+
recovery_action: recoveryAction,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function output(id, label, extensions, mimeTypes) {
|
|
382
|
+
return {
|
|
383
|
+
id,
|
|
384
|
+
label,
|
|
385
|
+
required: true,
|
|
386
|
+
extensions,
|
|
387
|
+
mime_types: mimeTypes,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function rule(id, outputId, kind, config = {}) {
|
|
392
|
+
return { id, output_id: outputId, kind, config };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function validateAcceptanceRule(acceptance, artifact, outputsRoot) {
|
|
396
|
+
if (!artifact) {
|
|
397
|
+
throw acceptanceError(acceptance, "The required scenario output is missing.");
|
|
398
|
+
}
|
|
399
|
+
if (acceptance.kind === "artifact_present" || acceptance.kind === "extension_mime_match") {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (acceptance.kind === "non_empty") {
|
|
403
|
+
if (!Number.isSafeInteger(artifact.size_bytes) || artifact.size_bytes < 1) {
|
|
404
|
+
throw acceptanceError(acceptance, `${artifact.name} is empty.`);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const bytes = await readArtifactBytes(artifact, outputsRoot);
|
|
410
|
+
if (acceptance.kind === "citations_present") {
|
|
411
|
+
const text = bytes.toString("utf8");
|
|
412
|
+
const markdownLinks = text.match(/\[[^\]\n]+\]\(https?:\/\/[^)\s]+\)/gi) || [];
|
|
413
|
+
if (markdownLinks.length === 0) {
|
|
414
|
+
throw acceptanceError(
|
|
415
|
+
acceptance,
|
|
416
|
+
"The research report must cite at least one inspected source with a Markdown link.",
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (acceptance.kind === "structured_workbook") {
|
|
422
|
+
// The shared file capability gate has already verified an OOXML workbook root,
|
|
423
|
+
// content type, central directory limits, MIME, extension, size, and digest.
|
|
424
|
+
if (artifact.mime_type !== OOXML_SHEET) {
|
|
425
|
+
throw acceptanceError(acceptance, "The workbook is not a verified Excel file.");
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (acceptance.kind === "page_count") {
|
|
430
|
+
const pages = await validatedPdfPageCount(bytes, acceptance);
|
|
431
|
+
if (pages < Number(acceptance.config.min || 1)) {
|
|
432
|
+
throw acceptanceError(acceptance, "The PDF does not contain a reviewable page.");
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (acceptance.kind === "image_dimensions") {
|
|
437
|
+
const { width, height } = validatedPngDimensions(bytes, acceptance);
|
|
438
|
+
if (
|
|
439
|
+
width < Number(acceptance.config.min_width || 1) ||
|
|
440
|
+
height < Number(acceptance.config.min_height || 1)
|
|
441
|
+
) {
|
|
442
|
+
throw acceptanceError(
|
|
443
|
+
acceptance,
|
|
444
|
+
`The image must be at least ${acceptance.config.min_width}×${acceptance.config.min_height}px.`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
throw acceptanceError(acceptance, "The scenario contains an unsupported acceptance rule.");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function validatedPdfPageCount(bytes, acceptance) {
|
|
453
|
+
if (bytes.length < 8 || bytes.length > MAX_SCENARIO_PDF_BYTES) {
|
|
454
|
+
throw acceptanceError(acceptance, "The PDF is empty, incomplete, or too large to inspect.");
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const document = await PDFDocument.load(bytes, {
|
|
458
|
+
ignoreEncryption: false,
|
|
459
|
+
updateMetadata: false,
|
|
460
|
+
throwOnInvalidObject: true,
|
|
461
|
+
});
|
|
462
|
+
const pages = document.getPageCount();
|
|
463
|
+
if (!Number.isSafeInteger(pages) || pages < 0) {
|
|
464
|
+
throw new Error("invalid page count");
|
|
465
|
+
}
|
|
466
|
+
return pages;
|
|
467
|
+
} catch {
|
|
468
|
+
throw acceptanceError(
|
|
469
|
+
acceptance,
|
|
470
|
+
"The PDF could not be parsed into a usable catalog and page tree.",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function validatedPngDimensions(bytes, acceptance) {
|
|
476
|
+
if (bytes.length < 57 || bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") {
|
|
477
|
+
throw acceptanceError(acceptance, "The image is not a complete PNG.");
|
|
478
|
+
}
|
|
479
|
+
const headerLength = bytes.readUInt32BE(8);
|
|
480
|
+
const headerType = bytes.subarray(12, 16).toString("ascii");
|
|
481
|
+
const width = bytes.readUInt32BE(16);
|
|
482
|
+
const height = bytes.readUInt32BE(20);
|
|
483
|
+
const bitDepth = bytes[24];
|
|
484
|
+
const colorType = bytes[25];
|
|
485
|
+
const compression = bytes[26];
|
|
486
|
+
const filterMethod = bytes[27];
|
|
487
|
+
const interlace = bytes[28];
|
|
488
|
+
const pixels = width * height;
|
|
489
|
+
const supportedDepths = {
|
|
490
|
+
0: [1, 2, 4, 8, 16],
|
|
491
|
+
2: [8, 16],
|
|
492
|
+
3: [1, 2, 4, 8],
|
|
493
|
+
4: [8, 16],
|
|
494
|
+
6: [8, 16],
|
|
495
|
+
};
|
|
496
|
+
if (
|
|
497
|
+
headerLength !== 13 ||
|
|
498
|
+
headerType !== "IHDR" ||
|
|
499
|
+
width < 1 ||
|
|
500
|
+
height < 1 ||
|
|
501
|
+
width > MAX_SCENARIO_PNG_DIMENSION ||
|
|
502
|
+
height > MAX_SCENARIO_PNG_DIMENSION ||
|
|
503
|
+
!Number.isSafeInteger(pixels) ||
|
|
504
|
+
pixels > MAX_SCENARIO_PNG_PIXELS ||
|
|
505
|
+
!supportedDepths[colorType]?.includes(bitDepth) ||
|
|
506
|
+
compression !== 0 ||
|
|
507
|
+
filterMethod !== 0 ||
|
|
508
|
+
![0, 1].includes(interlace)
|
|
509
|
+
) {
|
|
510
|
+
throw acceptanceError(acceptance, "The PNG header or decoded canvas size is invalid.");
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
const idat = collectPngImageData(bytes);
|
|
514
|
+
const scanlines = pngScanlineLengths(width, height, bitDepth, colorType, interlace);
|
|
515
|
+
const expectedLength = scanlines.reduce(
|
|
516
|
+
(total, { rows, rowBytes }) => total + rows * (rowBytes + 1),
|
|
517
|
+
0,
|
|
518
|
+
);
|
|
519
|
+
const inflated = inflateSync(Buffer.concat(idat), {
|
|
520
|
+
maxOutputLength: expectedLength + 1,
|
|
521
|
+
});
|
|
522
|
+
if (inflated.length !== expectedLength) {
|
|
523
|
+
throw new Error("decoded scanline length mismatch");
|
|
524
|
+
}
|
|
525
|
+
let scanlineOffset = 0;
|
|
526
|
+
for (const { rows, rowBytes } of scanlines) {
|
|
527
|
+
for (let row = 0; row < rows; row += 1) {
|
|
528
|
+
if (inflated[scanlineOffset] > 4) throw new Error("invalid PNG filter");
|
|
529
|
+
scanlineOffset += rowBytes + 1;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const decoded = PNG.sync.read(bytes, { checkCRC: true });
|
|
533
|
+
if (
|
|
534
|
+
decoded.width !== width ||
|
|
535
|
+
decoded.height !== height ||
|
|
536
|
+
!Buffer.isBuffer(decoded.data) ||
|
|
537
|
+
decoded.data.length !== pixels * 4
|
|
538
|
+
) {
|
|
539
|
+
throw new Error("decoded canvas mismatch");
|
|
540
|
+
}
|
|
541
|
+
} catch {
|
|
542
|
+
throw acceptanceError(
|
|
543
|
+
acceptance,
|
|
544
|
+
"The PNG image data could not be decoded into a complete pixel canvas.",
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return { width, height };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function collectPngImageData(bytes) {
|
|
551
|
+
const chunks = [];
|
|
552
|
+
let offset = 8;
|
|
553
|
+
let ended = false;
|
|
554
|
+
while (offset < bytes.length) {
|
|
555
|
+
if (offset + 12 > bytes.length) throw new Error("truncated PNG chunk");
|
|
556
|
+
const length = bytes.readUInt32BE(offset);
|
|
557
|
+
const dataStart = offset + 8;
|
|
558
|
+
const dataEnd = dataStart + length;
|
|
559
|
+
const chunkEnd = dataEnd + 4;
|
|
560
|
+
if (chunkEnd > bytes.length) throw new Error("truncated PNG chunk data");
|
|
561
|
+
const type = bytes.subarray(offset + 4, dataStart).toString("ascii");
|
|
562
|
+
if (type === "IDAT") chunks.push(bytes.subarray(dataStart, dataEnd));
|
|
563
|
+
if (type === "IEND") {
|
|
564
|
+
if (length !== 0 || chunkEnd !== bytes.length) throw new Error("invalid PNG end");
|
|
565
|
+
ended = true;
|
|
566
|
+
}
|
|
567
|
+
offset = chunkEnd;
|
|
568
|
+
}
|
|
569
|
+
if (!ended || chunks.length === 0) throw new Error("missing PNG image data");
|
|
570
|
+
return chunks;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function pngScanlineLengths(width, height, bitDepth, colorType, interlace) {
|
|
574
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
|
|
575
|
+
const rowBytes = (passWidth) => Math.ceil((passWidth * channels * bitDepth) / 8);
|
|
576
|
+
if (interlace === 0) return [{ rows: height, rowBytes: rowBytes(width) }];
|
|
577
|
+
const passes = [
|
|
578
|
+
[0, 0, 8, 8],
|
|
579
|
+
[4, 0, 8, 8],
|
|
580
|
+
[0, 4, 4, 8],
|
|
581
|
+
[2, 0, 4, 4],
|
|
582
|
+
[0, 2, 2, 4],
|
|
583
|
+
[1, 0, 2, 2],
|
|
584
|
+
[0, 1, 1, 2],
|
|
585
|
+
];
|
|
586
|
+
return passes.flatMap(([xStart, yStart, xStep, yStep]) => {
|
|
587
|
+
const passWidth = width > xStart ? Math.ceil((width - xStart) / xStep) : 0;
|
|
588
|
+
const rows = height > yStart ? Math.ceil((height - yStart) / yStep) : 0;
|
|
589
|
+
return passWidth > 0 && rows > 0 ? [{ rows, rowBytes: rowBytes(passWidth) }] : [];
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function readArtifactBytes(artifact, outputsRoot) {
|
|
594
|
+
const root = path.resolve(String(outputsRoot || ""));
|
|
595
|
+
const target = path.resolve(root, String(artifact.relative_path || ""));
|
|
596
|
+
if (!root || !target.startsWith(`${root}${path.sep}`)) {
|
|
597
|
+
throw scenarioError(
|
|
598
|
+
"The scenario output is outside the managed output directory.",
|
|
599
|
+
"local_task_scenario_artifact_unsafe",
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
const [resolvedRoot, resolvedTarget, details] = await Promise.all([
|
|
603
|
+
realpath(root),
|
|
604
|
+
realpath(target),
|
|
605
|
+
lstat(target),
|
|
606
|
+
]);
|
|
607
|
+
if (
|
|
608
|
+
!details.isFile() ||
|
|
609
|
+
details.isSymbolicLink() ||
|
|
610
|
+
!resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)
|
|
611
|
+
) {
|
|
612
|
+
throw scenarioError(
|
|
613
|
+
"The scenario output failed SIMY's managed-file safety check.",
|
|
614
|
+
"local_task_scenario_artifact_unsafe",
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
const bytes = await readFile(resolvedTarget);
|
|
618
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
619
|
+
if (
|
|
620
|
+
bytes.length !== artifact.size_bytes ||
|
|
621
|
+
digest !== String(artifact.sha256 || "").toLowerCase()
|
|
622
|
+
) {
|
|
623
|
+
throw scenarioError(
|
|
624
|
+
"The scenario output changed after verification.",
|
|
625
|
+
"local_task_scenario_artifact_changed",
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
return bytes;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function acceptanceInstruction(value) {
|
|
632
|
+
if (value.kind === "citations_present") {
|
|
633
|
+
return "The research report must contain Markdown links to sources actually inspected.";
|
|
634
|
+
}
|
|
635
|
+
if (value.kind === "structured_workbook") {
|
|
636
|
+
return "The workbook must be a valid, structured .xlsx file.";
|
|
637
|
+
}
|
|
638
|
+
if (value.kind === "page_count") {
|
|
639
|
+
return `The PDF must contain at least ${value.config.min} page.`;
|
|
640
|
+
}
|
|
641
|
+
if (value.kind === "image_dimensions") {
|
|
642
|
+
return `The PNG must be at least ${value.config.min_width}×${value.config.min_height}px.`;
|
|
643
|
+
}
|
|
644
|
+
return `${value.output_id} must pass ${value.kind.replaceAll("_", " ")}.`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function acceptanceError(value, message) {
|
|
648
|
+
return scenarioError(
|
|
649
|
+
message,
|
|
650
|
+
"local_task_scenario_acceptance_failed",
|
|
651
|
+
null,
|
|
652
|
+
value.output_id,
|
|
653
|
+
value.id,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function scenarioError(
|
|
658
|
+
message,
|
|
659
|
+
code = "local_task_scenario_pack_invalid",
|
|
660
|
+
recovery = null,
|
|
661
|
+
outputId = null,
|
|
662
|
+
ruleId = null,
|
|
663
|
+
) {
|
|
664
|
+
return new LocalTaskScenarioPackError(message, {
|
|
665
|
+
code,
|
|
666
|
+
recovery,
|
|
667
|
+
outputId,
|
|
668
|
+
ruleId,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function isRecord(value) {
|
|
673
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function deepFreeze(value) {
|
|
677
|
+
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
678
|
+
Object.freeze(value);
|
|
679
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
680
|
+
return value;
|
|
681
|
+
}
|