@arnilo/prism-tools 0.0.0-stage → 0.13.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/CHANGELOG.md +418 -0
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/browser/README.md +98 -0
- package/browser/skills/browser-automation/SKILL.md +14 -0
- package/browser/src/actions/cdp.ts +131 -0
- package/browser/src/actions/dialog.ts +25 -0
- package/browser/src/actions/download_release.ts +22 -0
- package/browser/src/actions/index.ts +37 -0
- package/browser/src/actions/locator.ts +70 -0
- package/browser/src/actions/navigate.ts +23 -0
- package/browser/src/actions/screenshot.ts +27 -0
- package/browser/src/actions/scroll.ts +24 -0
- package/browser/src/actions/select_page.ts +21 -0
- package/browser/src/actions/types.ts +114 -0
- package/browser/src/actions/upload.ts +44 -0
- package/browser/src/actions/wait.ts +45 -0
- package/browser/src/browser.ts +197 -0
- package/browser/src/cdp.ts +178 -0
- package/browser/src/checkpoint.ts +189 -0
- package/browser/src/config.ts +72 -0
- package/browser/src/downloads.ts +253 -0
- package/browser/src/errors.ts +31 -0
- package/browser/src/evaluate.ts +101 -0
- package/browser/src/index.ts +129 -0
- package/browser/src/limits.ts +271 -0
- package/browser/src/manager.ts +730 -0
- package/browser/src/network.ts +263 -0
- package/browser/src/observe.ts +178 -0
- package/browser/src/policy.ts +133 -0
- package/browser/src/screenshot.ts +107 -0
- package/browser/src/shared-sandbox.ts +53 -0
- package/browser/src/snapshot.ts +99 -0
- package/browser/src/targets.ts +165 -0
- package/browser/src/tools.ts +480 -0
- package/browser/src/types.ts +316 -0
- package/browser/src/uploads.ts +174 -0
- package/coding/README.md +337 -0
- package/coding/src/agent/acp-operations.ts +146 -0
- package/coding/src/agent/artifacts.ts +38 -0
- package/coding/src/agent/ask-user-decision.ts +623 -0
- package/coding/src/agent/atomic-write.ts +23 -0
- package/coding/src/agent/bounded-file.ts +21 -0
- package/coding/src/agent/checks.ts +301 -0
- package/coding/src/agent/coding-checkpoint.ts +828 -0
- package/coding/src/agent/delete.ts +237 -0
- package/coding/src/agent/diagnostics-tool.ts +163 -0
- package/coding/src/agent/diagnostics.ts +262 -0
- package/coding/src/agent/edit-diff.ts +382 -0
- package/coding/src/agent/edit.ts +322 -0
- package/coding/src/agent/effects.ts +102 -0
- package/coding/src/agent/env.ts +28 -0
- package/coding/src/agent/execution-policy.ts +89 -0
- package/coding/src/agent/file-mutation-queue.ts +72 -0
- package/coding/src/agent/forge/github.ts +603 -0
- package/coding/src/agent/forge/index.ts +16 -0
- package/coding/src/agent/forge/types.ts +185 -0
- package/coding/src/agent/git-aware-repository.ts +305 -0
- package/coding/src/agent/git-exec.ts +326 -0
- package/coding/src/agent/git-status.ts +177 -0
- package/coding/src/agent/git-tools.ts +360 -0
- package/coding/src/agent/git.ts +1074 -0
- package/coding/src/agent/glob-match.ts +146 -0
- package/coding/src/agent/glob.ts +182 -0
- package/coding/src/agent/goal-verify.ts +385 -0
- package/coding/src/agent/grep-worker.ts +74 -0
- package/coding/src/agent/grep.ts +459 -0
- package/coding/src/agent/identity.ts +48 -0
- package/coding/src/agent/index.ts +676 -0
- package/coding/src/agent/language/client.ts +429 -0
- package/coding/src/agent/language/framing.ts +190 -0
- package/coding/src/agent/language/index.ts +23 -0
- package/coding/src/agent/language/intelligence.ts +682 -0
- package/coding/src/agent/language/types.ts +187 -0
- package/coding/src/agent/lifecycle-events.ts +121 -0
- package/coding/src/agent/lifecycle.ts +166 -0
- package/coding/src/agent/limits.ts +214 -0
- package/coding/src/agent/move.ts +178 -0
- package/coding/src/agent/mutation-path.ts +52 -0
- package/coding/src/agent/output-accumulator.ts +304 -0
- package/coding/src/agent/path-utils.ts +120 -0
- package/coding/src/agent/process/index.ts +58 -0
- package/coding/src/agent/process/recovery.ts +508 -0
- package/coding/src/agent/process/sessions-host.ts +222 -0
- package/coding/src/agent/process/sessions-monitor.ts +141 -0
- package/coding/src/agent/process/sessions-recovery.ts +241 -0
- package/coding/src/agent/process/sessions-spawn.ts +613 -0
- package/coding/src/agent/process/sessions-teardown.ts +281 -0
- package/coding/src/agent/process/sessions.ts +30 -0
- package/coding/src/agent/process/types.ts +358 -0
- package/coding/src/agent/read-path-set.ts +126 -0
- package/coding/src/agent/read.ts +713 -0
- package/coding/src/agent/repository/glob.ts +147 -0
- package/coding/src/agent/repository/indexed-search.ts +480 -0
- package/coding/src/agent/repository/list.ts +131 -0
- package/coding/src/agent/repository/operations.ts +22 -0
- package/coding/src/agent/repository/path.ts +98 -0
- package/coding/src/agent/repository/search.ts +277 -0
- package/coding/src/agent/repository/types.ts +243 -0
- package/coding/src/agent/repository/walk.ts +126 -0
- package/coding/src/agent/repository.ts +12 -0
- package/coding/src/agent/shell-background.ts +264 -0
- package/coding/src/agent/shell-output.ts +75 -0
- package/coding/src/agent/shell.ts +526 -0
- package/coding/src/agent/todo.ts +399 -0
- package/coding/src/agent/truncate.ts +251 -0
- package/coding/src/agent/write.ts +170 -0
- package/coding/src/ask-user-host.ts +48 -0
- package/coding/src/config.ts +278 -0
- package/coding/src/hard-deny.ts +97 -0
- package/coding/src/index.ts +318 -0
- package/coding/src/principal-guard.ts +84 -0
- package/coding/src/sandbox.ts +92 -0
- package/coding/src/security/approval.ts +144 -0
- package/coding/src/security/command-rules.ts +57 -0
- package/coding/src/security/docker-cli.ts +222 -0
- package/coding/src/security/docker-sandbox.ts +1205 -0
- package/coding/src/security/e2b-sandbox.ts +853 -0
- package/coding/src/security/egress/dns-pin.ts +88 -0
- package/coding/src/security/egress/index.ts +32 -0
- package/coding/src/security/egress/limits.ts +57 -0
- package/coding/src/security/egress/policy.ts +137 -0
- package/coding/src/security/egress/proxy.ts +599 -0
- package/coding/src/security/egress/types.ts +30 -0
- package/coding/src/security/index.ts +199 -0
- package/coding/src/security/native-sandbox.ts +639 -0
- package/coding/src/security/path-containment.ts +55 -0
- package/coding/src/security/sandbox-coding-operations.ts +392 -0
- package/coding/src/security/sandbox-fs-operations.ts +644 -0
- package/coding/src/security/sandbox-limits.ts +147 -0
- package/coding/src/security/sandbox-tar.ts +308 -0
- package/coding/src/security/sandbox.ts +179 -0
- package/coding/src/security/semaphore.ts +55 -0
- package/coding/src/targets.ts +110 -0
- package/coding/src/workspace/diff.ts +78 -0
- package/coding/src/workspace/errors.ts +29 -0
- package/coding/src/workspace/index.ts +5 -0
- package/coding/src/workspace/merge.ts +151 -0
- package/coding/src/workspace/routing.ts +76 -0
- package/coding/src/workspace/service.ts +584 -0
- package/computer-use/README.md +105 -0
- package/computer-use/skills/computer-use-linux/SKILL.md +17 -0
- package/computer-use/src/classify.ts +53 -0
- package/computer-use/src/command.ts +29 -0
- package/computer-use/src/config.ts +69 -0
- package/computer-use/src/create.ts +170 -0
- package/computer-use/src/devices.ts +247 -0
- package/computer-use/src/env.ts +28 -0
- package/computer-use/src/index.ts +157 -0
- package/computer-use/src/skill.ts +21 -0
- package/openapi/README.md +97 -0
- package/openapi/src/compile.ts +386 -0
- package/openapi/src/config.ts +73 -0
- package/openapi/src/errors.ts +16 -0
- package/openapi/src/execute.ts +241 -0
- package/openapi/src/index.ts +124 -0
- package/openapi/src/limits.ts +63 -0
- package/openapi/src/specs.ts +86 -0
- package/openapi/src/tools.ts +60 -0
- package/openapi/src/types.ts +61 -0
- package/package.json +171 -4
- package/research/README.md +72 -0
- package/research/skills/research-report/SKILL.md +12 -0
- package/research/skills/research-scoping/SKILL.md +10 -0
- package/research/skills/research-synthesis/SKILL.md +10 -0
- package/research/skills/source-triage/SKILL.md +10 -0
- package/research/src/check.ts +71 -0
- package/research/src/config.ts +41 -0
- package/research/src/index.ts +147 -0
- package/research/src/ledger.ts +157 -0
- package/research/src/urls.ts +21 -0
- package/web/README.md +287 -0
- package/web/skills/web-research/SKILL.md +17 -0
- package/web/src/brave.ts +66 -0
- package/web/src/cache.ts +151 -0
- package/web/src/config.ts +210 -0
- package/web/src/controls.ts +128 -0
- package/web/src/domains.ts +114 -0
- package/web/src/exa.ts +60 -0
- package/web/src/firecrawl.ts +160 -0
- package/web/src/index.ts +387 -0
- package/web/src/kagi.ts +73 -0
- package/web/src/limits.ts +55 -0
- package/web/src/native/convert.ts +84 -0
- package/web/src/native/fetch.ts +226 -0
- package/web/src/normalize.ts +131 -0
- package/web/src/obscura/cdp.ts +201 -0
- package/web/src/obscura/classify.ts +35 -0
- package/web/src/obscura/cli.ts +254 -0
- package/web/src/obscura/errors.ts +8 -0
- package/web/src/obscura/index.ts +59 -0
- package/web/src/obscura/limits.ts +92 -0
- package/web/src/obscura/mcp.ts +104 -0
- package/web/src/obscura/playwright.ts +133 -0
- package/web/src/obscura/process.ts +235 -0
- package/web/src/obscura/search-profile.ts +73 -0
- package/web/src/obscura/types.ts +48 -0
- package/web/src/obscura/web.ts +419 -0
- package/web/src/prompt-fetch.ts +88 -0
- package/web/src/render.ts +75 -0
- package/web/src/searxng.ts +78 -0
- package/web/src/slice.ts +139 -0
- package/web/src/tavily.ts +77 -0
- package/web/src/tools.ts +182 -0
- package/web/src/transport.ts +310 -0
- package/web/src/types.ts +199 -0
- package/work/README.md +77 -0
- package/work/THIRD_PARTY_NOTICES +24 -0
- package/work/docling/ocr.py +150 -0
- package/work/sandbox/Dockerfile +36 -0
- package/work/sandbox/soffice.sh +12 -0
- package/work/src/command.ts +29 -0
- package/work/src/config.ts +138 -0
- package/work/src/connectors/cli.ts +343 -0
- package/work/src/connectors/drafts.ts +647 -0
- package/work/src/connectors/errors.ts +8 -0
- package/work/src/connectors/file-bytes.ts +149 -0
- package/work/src/connectors/google-workspace-http.ts +399 -0
- package/work/src/connectors/google-workspace.ts +465 -0
- package/work/src/connectors/gws-tools.ts +378 -0
- package/work/src/connectors/http.ts +123 -0
- package/work/src/connectors/idempotency.ts +235 -0
- package/work/src/connectors/identity.ts +34 -0
- package/work/src/connectors/index.ts +93 -0
- package/work/src/connectors/limits.ts +50 -0
- package/work/src/connectors/m365-tools.ts +308 -0
- package/work/src/connectors/microsoft365-http.ts +428 -0
- package/work/src/connectors/microsoft365.ts +402 -0
- package/work/src/connectors/normalize.ts +120 -0
- package/work/src/connectors/tool-helpers.ts +398 -0
- package/work/src/connectors/tools.ts +42 -0
- package/work/src/connectors/types.ts +399 -0
- package/work/src/diagrams/canonicalize.ts +166 -0
- package/work/src/diagrams/caps.ts +52 -0
- package/work/src/diagrams/embed.ts +340 -0
- package/work/src/diagrams/errors.ts +76 -0
- package/work/src/diagrams/index.ts +81 -0
- package/work/src/diagrams/messages.ts +150 -0
- package/work/src/diagrams/origin.ts +52 -0
- package/work/src/diagrams/telemetry.ts +34 -0
- package/work/src/diagrams/xml.ts +267 -0
- package/work/src/document-extraction/index.ts +563 -0
- package/work/src/document-reader/ambient.d.ts +2 -0
- package/work/src/document-reader/errors.ts +7 -0
- package/work/src/document-reader/index.ts +181 -0
- package/work/src/document-reader/mistral-ocr.ts +318 -0
- package/work/src/document-reader/parsers.ts +171 -0
- package/work/src/documents/caps.ts +135 -0
- package/work/src/documents/diff.ts +269 -0
- package/work/src/documents/errors.ts +44 -0
- package/work/src/documents/fidelity.ts +98 -0
- package/work/src/documents/generate.ts +96 -0
- package/work/src/documents/hash.ts +8 -0
- package/work/src/documents/index.ts +14 -0
- package/work/src/documents/model-schema.ts +639 -0
- package/work/src/documents/parse.ts +252 -0
- package/work/src/documents/patch-history.ts +81 -0
- package/work/src/documents/patch.ts +476 -0
- package/work/src/documents/preview-html.ts +245 -0
- package/work/src/documents/preview.ts +227 -0
- package/work/src/documents/telemetry.ts +36 -0
- package/work/src/documents/translate/docx.ts +292 -0
- package/work/src/documents/translate/pptx.ts +304 -0
- package/work/src/documents/translate/xlsx.ts +137 -0
- package/work/src/documents/types.ts +188 -0
- package/work/src/index.ts +311 -0
- package/work/src/runtime/worker-pool-entry.ts +102 -0
- package/work/src/runtime/worker-pool.ts +267 -0
- package/work/src/sandbox/composition.ts +212 -0
- package/work/src/sandbox/image.ts +6 -0
- package/work/src/sandbox/index.ts +14 -0
- package/work/src/sheets/caps.ts +68 -0
- package/work/src/sheets/csv.ts +351 -0
- package/work/src/sheets/decimal.ts +388 -0
- package/work/src/sheets/errors.ts +52 -0
- package/work/src/sheets/index.ts +62 -0
- package/work/src/sheets/inference.ts +378 -0
- package/work/src/sheets/telemetry.ts +34 -0
- package/work/src/sheets/types.ts +162 -0
- package/work/src/sheets/xlsx.ts +388 -0
- package/work/src/skills/index.ts +8 -0
- package/work/src/skills/load.ts +43 -0
- package/work/src/tools/filesystem.ts +21 -0
- package/work/src/tools/index.ts +1 -0
- package/work/src/tools/local-filesystem.ts +33 -0
- package/work/src/tools/office.ts +311 -0
- package/work/vendor/hermes-agent/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/SKILL.md +196 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_comments.py +289 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_common.py +94 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_create.py +177 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_edit.py +250 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_read.py +149 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_revisions.py +147 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_template.py +70 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_validate.py +156 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/SKILL.md +125 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/_raster.py +76 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_marker.py +88 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_pymupdf.py +101 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_create.py +130 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_fill_form.py +97 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_form_layout.py +168 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_make_form.py +145 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_merge.py +50 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_meta.py +115 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_page_image.py +99 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_read.py +153 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_secure.py +71 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_split.py +84 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_stamp.py +143 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_watermark.py +51 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/SKILL.md +220 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_create.py +214 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_edit.py +436 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_from_template.py +88 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_read.py +131 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_render.py +93 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/SKILL.md +196 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/csv_to_xlsx.py +104 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_create.py +259 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_edit.py +263 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_read.py +160 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_recalc.py +111 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_restructure.py +337 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_to_csv.py +69 -0
- package/work/vendor-lock.json +11 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import { DocumentReaderError } from "../document-reader/errors.js";
|
|
3
|
+
import type { DocumentParser } from "../document-reader/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Module worker entry for `worker-pool.ts` (plan 127 Task 2). The parent sends typed
|
|
7
|
+
* `{ kind, payload }` objects; handlers are a static map — never a path or source from a message.
|
|
8
|
+
*
|
|
9
|
+
* Product handlers (plan 127 Task 3) run the measured CPU-bound cores off the agent's event loop.
|
|
10
|
+
* Only `document.extract` survived the Task 3 revert: the sheets/documents model parses cloned a
|
|
11
|
+
* result larger than the input and regressed their own wall clock (see evidence §9), while the
|
|
12
|
+
* document reader's extract returns literal text and gained loop freedom at ~no cost. The handler
|
|
13
|
+
* calls the same parser the inline path calls; inside a worker `shouldOffloadToPool()` is false,
|
|
14
|
+
* so it cannot recursively spawn another pool. `probe.*` handlers are the pool's own self-check
|
|
15
|
+
* surface.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type WorkerPoolReply = { ok: true; value: unknown } | { ok: false; error: { name: string; message: string } };
|
|
19
|
+
|
|
20
|
+
type Handler = (payload: unknown) => unknown | Promise<unknown>;
|
|
21
|
+
|
|
22
|
+
let workerDocumentParsers: Promise<Map<string, DocumentParser>> | undefined;
|
|
23
|
+
|
|
24
|
+
/** Same default wiring as `createDocumentReader`, built once per worker and memoized. */
|
|
25
|
+
async function documentParsers(): Promise<Map<string, DocumentParser>> {
|
|
26
|
+
workerDocumentParsers ??= (async () => {
|
|
27
|
+
const { createDocxParser, createOoxmlParser, createPdfParser } = await import("../document-reader/parsers.js");
|
|
28
|
+
const parsers = [
|
|
29
|
+
await createPdfParser(),
|
|
30
|
+
await createDocxParser(),
|
|
31
|
+
createOoxmlParser("xlsx", "sheet", "xl/workbook.xml"),
|
|
32
|
+
createOoxmlParser("pptx", "deck", "ppt/presentation.xml"),
|
|
33
|
+
];
|
|
34
|
+
return new Map(parsers.map((parser) => [parser.format, parser]));
|
|
35
|
+
})();
|
|
36
|
+
return workerDocumentParsers;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HANDLERS: Record<string, Handler> = {
|
|
40
|
+
// --- product surface (plan 127 Task 3): the document reader's large-buffer extract ---
|
|
41
|
+
"document.extract": async (payload) => {
|
|
42
|
+
const { format, bytes, maxPages, maxTextBytes } = payload as {
|
|
43
|
+
format: string;
|
|
44
|
+
bytes: Uint8Array;
|
|
45
|
+
maxPages: number;
|
|
46
|
+
maxTextBytes: number;
|
|
47
|
+
};
|
|
48
|
+
const parser = (await documentParsers()).get(format);
|
|
49
|
+
if (!parser) throw new DocumentReaderError(`document-reader: no worker parser for format "${format}"`);
|
|
50
|
+
// The payload arrives as a plain Uint8Array (structured clone); the reader's byte gates accept it
|
|
51
|
+
// directly, so no Buffer wrap is needed (plan 127 further action #2).
|
|
52
|
+
return parser.extract(bytes, { maxPages, maxTextBytes });
|
|
53
|
+
},
|
|
54
|
+
// --- pool self-checks ---
|
|
55
|
+
"probe.echo": (payload) => payload,
|
|
56
|
+
"probe.spin": (payload) => {
|
|
57
|
+
const ms = typeof (payload as { ms?: unknown } | undefined)?.ms === "number" ? (payload as { ms: number }).ms : 0;
|
|
58
|
+
const end = Date.now() + Math.min(Math.max(ms, 0), 60_000);
|
|
59
|
+
while (Date.now() < end) {
|
|
60
|
+
// deliberately synchronous: the pool's bounded-concurrency test needs a blocking task
|
|
61
|
+
}
|
|
62
|
+
return { spunMs: ms };
|
|
63
|
+
},
|
|
64
|
+
// The kill-mid-task check: an uncaught throw inside the worker (never the parent process).
|
|
65
|
+
"probe.crash": () => {
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
throw new Error("planned worker crash (pool self-check)");
|
|
68
|
+
}, 10);
|
|
69
|
+
return new Promise<never>(() => {});
|
|
70
|
+
},
|
|
71
|
+
// Credential-free check: proves the pool's env allowlist reached this worker.
|
|
72
|
+
"probe.env": () => ({ envKeys: Object.keys(process.env).length, secret: process.env.PRISM_POOL_PROBE_SECRET ?? null }),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
async function handle(message: unknown): Promise<void> {
|
|
76
|
+
if (message === null || typeof message !== "object") return;
|
|
77
|
+
const task = message as { kind?: unknown; payload?: unknown };
|
|
78
|
+
const handler = typeof task.kind === "string" ? HANDLERS[task.kind] : undefined;
|
|
79
|
+
if (!handler) {
|
|
80
|
+
post({ ok: false, error: { name: "WorkerPoolError", message: `unknown worker task kind: ${String(task.kind)}` } });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
post({ ok: true, value: await handler(task.payload) });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
post({
|
|
87
|
+
ok: false,
|
|
88
|
+
error: {
|
|
89
|
+
name: error instanceof Error ? error.name : "Error",
|
|
90
|
+
message: error instanceof Error ? error.message : String(error),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function post(reply: WorkerPoolReply): void {
|
|
97
|
+
parentPort?.postMessage(reply);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
parentPort?.on("message", (message: unknown) => {
|
|
101
|
+
void handle(message);
|
|
102
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { isMainThread, Worker } from "node:worker_threads";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Internal bounded worker pool for the measured CPU-bound surfaces (plan 127 Task 2).
|
|
5
|
+
*
|
|
6
|
+
* Why it exists: `docs/_evidence/phase127-bun-concurrency.md` §3 measured four
|
|
7
|
+
* `cpu-offload-candidate` surfaces whose compute blocks the event loop far longer than a warm
|
|
8
|
+
* worker round-trip costs — PDF extract (~250-360 ms), docx parse (~52 ms), xlsx parse
|
|
9
|
+
* (~218-326 ms), csv parse (~50-65 ms) against 0.02 ms round-trip at 1 KiB … 7.2 ms at 16 MiB,
|
|
10
|
+
* i.e. ≥ 1 order of magnitude at every realistic payload. Three of those four were reverted
|
|
11
|
+
* (Task 3) because their *results* clone far slower than their inputs (§12) — the pricing rule
|
|
12
|
+
* that keeps that decision honest lives in `shouldOffloadToPool` below.
|
|
13
|
+
*
|
|
14
|
+
* Size: 8 x ~500 ms PDF extracts through a pool of N workers on a loaded 16-CPU host
|
|
15
|
+
* (2026-09-26, load/cpu ~1.1-1.5): wall 5032 / 1905 / 1122 / 920 ms and per-extract p50
|
|
16
|
+
* 542 / 471 / 548 / 852 ms for N = 1 / 2 / 4 / 8, with the parent 5 ms tick gap at 5.1 ms p50
|
|
17
|
+
* for every N. N = 2 is the winner: best per-task latency and the smallest co-tenant CPU claim
|
|
18
|
+
* (a bigger pool adds parallel CPU claim, not loop freedom, and the workspace suites are bounded
|
|
19
|
+
* to 2 concurrent leaves because those budgets flake at 3-4).
|
|
20
|
+
* ponytail: fixed 2 workers — raise only after re-measuring per-task latency and the co-tenant
|
|
21
|
+
* budget suites on an idle host.
|
|
22
|
+
*
|
|
23
|
+
* Fail-closed: a worker error or exit rejects its in-flight task (never hangs); the slot is
|
|
24
|
+
* dropped and replaced lazily by the next task. `closeWorkerPool()` rejects queued and in-flight
|
|
25
|
+
* tasks and terminates every worker (the next task spawns fresh ones). Workers get an explicit
|
|
26
|
+
* env allowlist (probe: a default Bun worker inherits all 69 parent env keys, including planted
|
|
27
|
+
* secrets, while `env: {…}` is honored and left 1 key). Nothing here logs payload contents;
|
|
28
|
+
* `workerPoolStats()` reports counts only.
|
|
29
|
+
*/
|
|
30
|
+
export const WORKER_POOL_MAX_WORKERS = 2;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Input size below which a hop cannot pay for itself (plan 127 Task 1 §3): at 64 KiB inline compute
|
|
34
|
+
* is ~7 ms (csv), ~20 ms (xlsx), ~52 ms (docx) and >50 ms (pdf) against a 0.063 ms round-trip, and
|
|
35
|
+
* below it a tiny parse must not pay a cold worker spawn. This is only the input half of the price —
|
|
36
|
+
* `shouldOffloadToPool` also charges the result side.
|
|
37
|
+
*/
|
|
38
|
+
export const WORKER_POOL_OFFLOAD_MIN_BYTES = 64 * 1024;
|
|
39
|
+
|
|
40
|
+
/** Measured one-way structured-clone prices, plan 127 §12 (Bun 1.4.2, loaded 16-CPU host). */
|
|
41
|
+
const FLAT_BYTES_PER_MS = 1_250_000; // Uint8Array: 1 MiB -> 0.71 ms, 4 MiB -> 2.24 ms, 16 MiB -> 13.07 ms
|
|
42
|
+
const RESULT_VALUES_PER_MS = 2_000; // model graphs: csv/xlsx 260k values -> 108/115 ms, docx 40k -> 12 ms
|
|
43
|
+
const WORKER_POOL_SCHEDULING_MS = 0.1; // measured warm dispatch + settle overhead
|
|
44
|
+
const OFFLOAD_MIN_COMPUTE_MARGIN = 10; // the moved pdf row is ~1500x; the reverted three were ~1-6x
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Price a hop in both directions before taking it (plan 127 further action #1).
|
|
48
|
+
*
|
|
49
|
+
* Task 1's break-even table priced only the input round-trip, and on that basis csv/xlsx/docx were
|
|
50
|
+
* offloaded and then reverted: their results are object graphs, which clone 100x slower per byte
|
|
51
|
+
* than a flat buffer (§9, §12), so the hop cost more than the parse it saved. Text and byte inputs
|
|
52
|
+
* are cheap (a 2 MB string clones in 0.06 ms — strings are shared, not copied), so the result shape
|
|
53
|
+
* is what decides. `resultValues` counts every value the clone must rebuild — containers plus leaf
|
|
54
|
+
* strings/numbers, e.g. `rows x (columns + 1)` for a sheet model — and `computeMs` is the measured
|
|
55
|
+
* (or per-unit-derived) inline cost. Offload only when the compute dwarfs both clones.
|
|
56
|
+
*/
|
|
57
|
+
export function shouldOffloadToPool(pricing: {
|
|
58
|
+
/** Bytes sent to the worker. */
|
|
59
|
+
readonly inputBytes: number;
|
|
60
|
+
/** Values the clone must rebuild in the result (containers plus leaf strings/numbers). */
|
|
61
|
+
readonly resultValues: number;
|
|
62
|
+
/** Expected inline compute time — measured, or derived from a measured per-unit cost. */
|
|
63
|
+
readonly computeMs: number;
|
|
64
|
+
}): boolean {
|
|
65
|
+
if (!isMainThread) return false;
|
|
66
|
+
if (pricing.inputBytes < WORKER_POOL_OFFLOAD_MIN_BYTES) return false;
|
|
67
|
+
const cloneMs = pricing.inputBytes / FLAT_BYTES_PER_MS + pricing.resultValues / RESULT_VALUES_PER_MS;
|
|
68
|
+
return pricing.computeMs >= OFFLOAD_MIN_COMPUTE_MARGIN * (cloneMs + WORKER_POOL_SCHEDULING_MS);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Typed task object crossing the thread boundary — never a function, never `eval`-shaped. */
|
|
72
|
+
export interface WorkerPoolTask {
|
|
73
|
+
/** Handler name registered in `worker-pool-entry.ts`, e.g. `probe.echo`. */
|
|
74
|
+
readonly kind: string;
|
|
75
|
+
/** Structured-cloneable payload. Byte caps stay at each caller's trust boundary. */
|
|
76
|
+
readonly payload?: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface WorkerPoolStats {
|
|
80
|
+
readonly workers: number;
|
|
81
|
+
readonly busy: number;
|
|
82
|
+
readonly queued: number;
|
|
83
|
+
readonly peak: number;
|
|
84
|
+
readonly completed: number;
|
|
85
|
+
readonly failed: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class WorkerPoolError extends Error {
|
|
89
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
90
|
+
super(message, options);
|
|
91
|
+
this.name = "WorkerPoolError";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A task that failed inside the worker; carries the remote error identity for revival. */
|
|
96
|
+
export class WorkerTaskError extends WorkerPoolError {
|
|
97
|
+
readonly remoteName: string;
|
|
98
|
+
readonly remoteMessage: string;
|
|
99
|
+
|
|
100
|
+
constructor(remoteName: string, remoteMessage: string) {
|
|
101
|
+
super(remoteMessage);
|
|
102
|
+
this.name = "WorkerTaskError";
|
|
103
|
+
this.remoteName = remoteName;
|
|
104
|
+
this.remoteMessage = remoteMessage;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rebuild the caller's own error class from a remote worker failure so `instanceof` checks keep
|
|
110
|
+
* working (each class takes a message; the worker reply carries name + message only). Returns
|
|
111
|
+
* `undefined` when the failure is not a known remote task error — callers rethrow the original.
|
|
112
|
+
*/
|
|
113
|
+
export function reviveWorkerTaskError(error: unknown, classes: readonly (new (message: string) => Error)[]): Error | undefined {
|
|
114
|
+
if (!(error instanceof WorkerTaskError)) return undefined;
|
|
115
|
+
for (const errorClass of classes) {
|
|
116
|
+
if (errorClass.name === error.remoteName) return new errorClass(error.remoteMessage);
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Job {
|
|
122
|
+
readonly task: WorkerPoolTask;
|
|
123
|
+
readonly resolve: (value: unknown) => void;
|
|
124
|
+
readonly reject: (error: Error) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface Slot {
|
|
128
|
+
readonly worker: Worker;
|
|
129
|
+
job: Job | null;
|
|
130
|
+
dead: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type WorkerReply = { ok: true; value: unknown } | { ok: false; error: { name: string; message: string } };
|
|
134
|
+
|
|
135
|
+
const WORKER_ENTRY = new URL("./worker-pool-entry.js", import.meta.url);
|
|
136
|
+
const ENV_ALLOWLIST = ["PATH", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL"] as const;
|
|
137
|
+
|
|
138
|
+
const slots = new Set<Slot>();
|
|
139
|
+
const idle: Slot[] = [];
|
|
140
|
+
const queue: Job[] = [];
|
|
141
|
+
let busy = 0;
|
|
142
|
+
let peak = 0;
|
|
143
|
+
let completed = 0;
|
|
144
|
+
let failed = 0;
|
|
145
|
+
|
|
146
|
+
/** Run one task on the pool; the pool spawns workers lazily and never hangs. */
|
|
147
|
+
export function runInPool<TOut = unknown>(task: WorkerPoolTask): Promise<TOut> {
|
|
148
|
+
return new Promise<TOut>((resolve, reject) => {
|
|
149
|
+
const job: Job = { task, resolve: resolve as (value: unknown) => void, reject };
|
|
150
|
+
const slot = idle.pop();
|
|
151
|
+
if (slot) {
|
|
152
|
+
dispatch(slot, job);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (slots.size < WORKER_POOL_MAX_WORKERS) {
|
|
156
|
+
dispatch(createSlot(), job);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
queue.push(job);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function workerPoolStats(): WorkerPoolStats {
|
|
164
|
+
return { workers: slots.size, busy, queued: queue.length, peak, completed, failed };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Terminate every worker and reject queued/in-flight tasks. Idempotent; the pool lazily spawns
|
|
169
|
+
* fresh workers on the next task, so a host (or a test file) that shut it down can still use it.
|
|
170
|
+
*/
|
|
171
|
+
export async function closeWorkerPool(): Promise<void> {
|
|
172
|
+
const error = new WorkerPoolError("worker pool is closed");
|
|
173
|
+
while (queue.length > 0) {
|
|
174
|
+
failed += 1;
|
|
175
|
+
queue.shift()?.reject(error);
|
|
176
|
+
}
|
|
177
|
+
await Promise.all([...slots].map((slot) => dropSlot(slot, error, { terminate: true })));
|
|
178
|
+
idle.length = 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function createSlot(): Slot {
|
|
182
|
+
const env: Record<string, string> = {};
|
|
183
|
+
for (const key of ENV_ALLOWLIST) {
|
|
184
|
+
const value = process.env[key];
|
|
185
|
+
if (value !== undefined) env[key] = value;
|
|
186
|
+
}
|
|
187
|
+
const worker = new Worker(WORKER_ENTRY, { env });
|
|
188
|
+
const slot: Slot = { worker, job: null, dead: false };
|
|
189
|
+
worker.on("message", (reply: WorkerReply) => settle(slot, reply));
|
|
190
|
+
worker.on("error", (error: Error) => {
|
|
191
|
+
void dropSlot(slot, new WorkerPoolError(`worker failed: ${error.message}`, { cause: error }));
|
|
192
|
+
});
|
|
193
|
+
worker.on("exit", (code: number) => {
|
|
194
|
+
void dropSlot(slot, new WorkerPoolError(`worker exited with code ${code}`));
|
|
195
|
+
});
|
|
196
|
+
worker.unref();
|
|
197
|
+
slots.add(slot);
|
|
198
|
+
return slot;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function dispatch(slot: Slot, job: Job): void {
|
|
202
|
+
slot.job = job;
|
|
203
|
+
busy += 1;
|
|
204
|
+
peak = Math.max(peak, busy);
|
|
205
|
+
slot.worker.ref();
|
|
206
|
+
try {
|
|
207
|
+
slot.worker.postMessage(job.task);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// Clone failure rejects this task; the worker itself is still healthy.
|
|
210
|
+
slot.job = null;
|
|
211
|
+
busy -= 1;
|
|
212
|
+
failed += 1;
|
|
213
|
+
idle.push(slot);
|
|
214
|
+
slot.worker.unref();
|
|
215
|
+
job.reject(new WorkerPoolError(`worker task is not structured-cloneable: ${(error as Error).message}`, { cause: error }));
|
|
216
|
+
pump();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function settle(slot: Slot, reply: WorkerReply): void {
|
|
221
|
+
const job = slot.job;
|
|
222
|
+
if (!job) return;
|
|
223
|
+
slot.job = null;
|
|
224
|
+
busy -= 1;
|
|
225
|
+
idle.push(slot);
|
|
226
|
+
slot.worker.unref();
|
|
227
|
+
if (reply?.ok === true) {
|
|
228
|
+
completed += 1;
|
|
229
|
+
job.resolve(reply.value);
|
|
230
|
+
} else {
|
|
231
|
+
failed += 1;
|
|
232
|
+
const detail = reply && reply.ok === false ? reply.error : undefined;
|
|
233
|
+
job.reject(new WorkerTaskError(detail?.name ?? "WorkerPoolError", detail?.message ?? "worker task failed"));
|
|
234
|
+
}
|
|
235
|
+
pump();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function pump(): void {
|
|
239
|
+
while (queue.length > 0) {
|
|
240
|
+
const slot = idle.pop();
|
|
241
|
+
if (slot) {
|
|
242
|
+
dispatch(slot, queue.shift() as Job);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (slots.size < WORKER_POOL_MAX_WORKERS) {
|
|
246
|
+
dispatch(createSlot(), queue.shift() as Job);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function dropSlot(slot: Slot, error: Error, options: { terminate?: boolean } = {}): Promise<void> {
|
|
254
|
+
if (slot.dead) return;
|
|
255
|
+
slot.dead = true;
|
|
256
|
+
slots.delete(slot);
|
|
257
|
+
const index = idle.indexOf(slot);
|
|
258
|
+
if (index >= 0) idle.splice(index, 1);
|
|
259
|
+
if (slot.job) {
|
|
260
|
+
slot.job.reject(error);
|
|
261
|
+
slot.job = null;
|
|
262
|
+
busy -= 1;
|
|
263
|
+
failed += 1;
|
|
264
|
+
}
|
|
265
|
+
if (options.terminate) await slot.worker.terminate();
|
|
266
|
+
pump();
|
|
267
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { JsonObject, ToolDefinition, ToolExecutionContext, ToolResult } from "@arnilo/prism";
|
|
2
|
+
import { createWorkTools } from "../connectors/tools.js";
|
|
3
|
+
import type { WorkToolsOptions } from "../connectors/types.js";
|
|
4
|
+
import type { DocumentReader } from "../document-reader/index.js";
|
|
5
|
+
import type { WorkSandboxFilesystem } from "../tools/filesystem.js";
|
|
6
|
+
import { createOfficeTools, type OfficeToolsOptions } from "../tools/office.js";
|
|
7
|
+
|
|
8
|
+
const TOKEN_ENV = /^(M365_|GOOGLE_)/;
|
|
9
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10
|
+
const SOFFICE = /(^|\/)soffice(\.bin)?$/;
|
|
11
|
+
const CAPABILITY_KEYS = [
|
|
12
|
+
"workspaceCoherent",
|
|
13
|
+
"filesystemIsolated",
|
|
14
|
+
"networkIsolated",
|
|
15
|
+
"processIsolated",
|
|
16
|
+
"privilegeIsolated",
|
|
17
|
+
"egressRestricted",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
const EMPTY_CAPABILITIES: WorkSandboxCapabilities = Object.freeze({
|
|
21
|
+
workspaceCoherent: false,
|
|
22
|
+
filesystemIsolated: false,
|
|
23
|
+
networkIsolated: false,
|
|
24
|
+
processIsolated: false,
|
|
25
|
+
privilegeIsolated: false,
|
|
26
|
+
egressRestricted: false,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** Default container env: no token names. Host may add non-token keys via `env`. */
|
|
30
|
+
export const DEFAULT_WORK_SANDBOX_ENV: Readonly<Record<string, string>> = Object.freeze({});
|
|
31
|
+
|
|
32
|
+
export class WorkSandboxError extends Error {
|
|
33
|
+
readonly code = "ERR_PRISM_WORK_SANDBOX";
|
|
34
|
+
constructor(message: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "WorkSandboxError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WorkSandboxCapabilities {
|
|
41
|
+
readonly workspaceCoherent: boolean;
|
|
42
|
+
readonly filesystemIsolated: boolean;
|
|
43
|
+
readonly networkIsolated: boolean;
|
|
44
|
+
readonly processIsolated: boolean;
|
|
45
|
+
readonly privilegeIsolated: boolean;
|
|
46
|
+
readonly egressRestricted: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkSandboxExecFileRequest {
|
|
50
|
+
readonly file: string;
|
|
51
|
+
readonly args: readonly string[];
|
|
52
|
+
readonly cwd?: string;
|
|
53
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
54
|
+
readonly timeout?: number;
|
|
55
|
+
readonly signal?: AbortSignal;
|
|
56
|
+
readonly onData?: (chunk: Uint8Array) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Duck-typed `DisposableSandbox` plus optional filesystem. Host injects; this package does not import coding-tools. */
|
|
60
|
+
export interface WorkSandbox {
|
|
61
|
+
readonly capabilities?: Readonly<WorkSandboxCapabilities>;
|
|
62
|
+
readonly root?: string;
|
|
63
|
+
execFile(request: WorkSandboxExecFileRequest): Promise<{ exitCode: number | null }>;
|
|
64
|
+
readFile?(path: string, options: { readonly maxBytes: number; readonly signal?: AbortSignal }): Promise<Uint8Array>;
|
|
65
|
+
writeFile?(path: string, bytes: Uint8Array, options: { readonly maxBytes: number; readonly signal?: AbortSignal }): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface WorkCompositionOptions {
|
|
69
|
+
readonly sandbox: WorkSandbox;
|
|
70
|
+
readonly connectors?: WorkToolsOptions;
|
|
71
|
+
readonly office?: OfficeToolsOptions;
|
|
72
|
+
readonly reader?: DocumentReader;
|
|
73
|
+
readonly filesystem?: WorkSandboxFilesystem;
|
|
74
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface WorkComposition {
|
|
78
|
+
readonly capabilities: WorkSandboxCapabilities;
|
|
79
|
+
readonly env: Readonly<Record<string, string>>;
|
|
80
|
+
readonly sandbox: WorkSandbox;
|
|
81
|
+
readonly reader?: DocumentReader;
|
|
82
|
+
execFile(request: WorkSandboxExecFileRequest): Promise<{ exitCode: number | null }>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface WorkCompositionResult {
|
|
86
|
+
readonly tools: readonly ToolDefinition[];
|
|
87
|
+
readonly composition: WorkComposition;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createWorkComposition(options: WorkCompositionOptions): WorkCompositionResult {
|
|
91
|
+
if (!options?.sandbox || typeof options.sandbox.execFile !== "function") {
|
|
92
|
+
throw new WorkSandboxError("sandbox.execFile is required");
|
|
93
|
+
}
|
|
94
|
+
const env = Object.freeze(assertWorkSandboxEnv(options.env ?? DEFAULT_WORK_SANDBOX_ENV));
|
|
95
|
+
const capabilities = resolveWorkSandboxCapabilities(options.sandbox.capabilities);
|
|
96
|
+
const filesystem = options.filesystem ?? asFilesystem(options.sandbox);
|
|
97
|
+
const execFile = (request: WorkSandboxExecFileRequest) => runWorkExecFile(options.sandbox, request, env);
|
|
98
|
+
|
|
99
|
+
const tools: ToolDefinition[] = [
|
|
100
|
+
...createOfficeTools({ ...options.office, ...(filesystem ? { filesystem } : {}) }),
|
|
101
|
+
workExecTool(execFile),
|
|
102
|
+
];
|
|
103
|
+
if (options.connectors) {
|
|
104
|
+
tools.push(...createWorkTools({ ...options.connectors, filesystem: options.connectors.filesystem ?? filesystem }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
tools,
|
|
109
|
+
composition: {
|
|
110
|
+
capabilities,
|
|
111
|
+
env,
|
|
112
|
+
sandbox: options.sandbox,
|
|
113
|
+
...(options.reader ? { reader: options.reader } : {}),
|
|
114
|
+
execFile,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function assertWorkSandboxEnv(env: Readonly<Record<string, string>>): Record<string, string> {
|
|
120
|
+
const out: Record<string, string> = {};
|
|
121
|
+
for (const [key, value] of Object.entries(env)) {
|
|
122
|
+
if (!ENV_NAME.test(key)) throw new WorkSandboxError(`invalid env name: ${key}`);
|
|
123
|
+
if (TOKEN_ENV.test(key)) throw new WorkSandboxError(`${key} is not allowed in the work sandbox`);
|
|
124
|
+
if (typeof value !== "string") throw new WorkSandboxError(`env value for ${key} must be a string`);
|
|
125
|
+
out[key] = value;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function resolveWorkSandboxCapabilities(metadata: Readonly<WorkSandboxCapabilities> | undefined): WorkSandboxCapabilities {
|
|
131
|
+
if (!metadata || typeof metadata !== "object") return EMPTY_CAPABILITIES;
|
|
132
|
+
const keys = Object.keys(metadata);
|
|
133
|
+
if (keys.length !== CAPABILITY_KEYS.length) return EMPTY_CAPABILITIES;
|
|
134
|
+
for (const key of CAPABILITY_KEYS) {
|
|
135
|
+
if (typeof (metadata as Record<string, unknown>)[key] !== "boolean") return EMPTY_CAPABILITIES;
|
|
136
|
+
}
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
workspaceCoherent: metadata.workspaceCoherent,
|
|
139
|
+
filesystemIsolated: metadata.filesystemIsolated,
|
|
140
|
+
networkIsolated: metadata.networkIsolated,
|
|
141
|
+
processIsolated: metadata.processIsolated,
|
|
142
|
+
privilegeIsolated: metadata.privilegeIsolated,
|
|
143
|
+
egressRestricted: metadata.egressRestricted,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function asFilesystem(sandbox: WorkSandbox): WorkSandboxFilesystem | undefined {
|
|
148
|
+
if (typeof sandbox.root !== "string" || !sandbox.root.startsWith("/")) return undefined;
|
|
149
|
+
if (typeof sandbox.readFile !== "function" || typeof sandbox.writeFile !== "function") return undefined;
|
|
150
|
+
return sandbox as WorkSandboxFilesystem;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function runWorkExecFile(
|
|
154
|
+
sandbox: WorkSandbox,
|
|
155
|
+
request: WorkSandboxExecFileRequest,
|
|
156
|
+
defaultEnv: Readonly<Record<string, string>>,
|
|
157
|
+
): Promise<{ exitCode: number | null }> {
|
|
158
|
+
if (!request.file || request.file.includes("\0")) throw new WorkSandboxError("execFile requires a non-empty file path");
|
|
159
|
+
if (!Array.isArray(request.args) || request.args.some((arg) => typeof arg !== "string" || arg.includes("\0"))) {
|
|
160
|
+
throw new WorkSandboxError("execFile args must be a string array without NUL");
|
|
161
|
+
}
|
|
162
|
+
const args = sofficeArgs(request.file, request.args);
|
|
163
|
+
const env = assertWorkSandboxEnv({ ...defaultEnv, ...request.env });
|
|
164
|
+
return sandbox.execFile({ ...request, args, env });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sofficeArgs(file: string, args: readonly string[]): readonly string[] {
|
|
168
|
+
if (!SOFFICE.test(file)) return args;
|
|
169
|
+
if (args.some((arg) => arg.startsWith("--accept") || /macro/i.test(arg))) {
|
|
170
|
+
throw new WorkSandboxError("LibreOffice macro/socket flags are not allowed");
|
|
171
|
+
}
|
|
172
|
+
if (args.some((arg) => arg.startsWith("-env:UserInstallation="))) return args;
|
|
173
|
+
return ["-env:UserInstallation=file:///tmp/lo-profile", "--headless", "--norestore", ...args];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function workExecTool(execFile: WorkComposition["execFile"]): ToolDefinition {
|
|
177
|
+
return {
|
|
178
|
+
name: "work_exec",
|
|
179
|
+
description: "Run an argv command inside the injected work sandbox. Results are untrusted.",
|
|
180
|
+
parameters: {
|
|
181
|
+
type: "object",
|
|
182
|
+
properties: {
|
|
183
|
+
file: { type: "string" },
|
|
184
|
+
args: { type: "array", items: { type: "string" } },
|
|
185
|
+
cwd: { type: "string" },
|
|
186
|
+
},
|
|
187
|
+
required: ["file", "args"],
|
|
188
|
+
additionalProperties: false,
|
|
189
|
+
},
|
|
190
|
+
effect: { kind: "external_mutation", idempotency: "unsupported" },
|
|
191
|
+
effects: ["execute"],
|
|
192
|
+
async execute(args: JsonObject, context: ToolExecutionContext): Promise<ToolResult> {
|
|
193
|
+
try {
|
|
194
|
+
const file = args.file;
|
|
195
|
+
if (typeof file !== "string" || !file) throw new WorkSandboxError("file must be a non-empty string");
|
|
196
|
+
const rawArgs = args.args;
|
|
197
|
+
if (!Array.isArray(rawArgs) || rawArgs.some((arg) => typeof arg !== "string")) {
|
|
198
|
+
throw new WorkSandboxError("args must be a string array");
|
|
199
|
+
}
|
|
200
|
+
const cwd = args.cwd;
|
|
201
|
+
if (cwd !== undefined && (typeof cwd !== "string" || !cwd.startsWith("/"))) {
|
|
202
|
+
throw new WorkSandboxError("cwd must be an absolute sandbox path");
|
|
203
|
+
}
|
|
204
|
+
const result = await execFile({ file, args: rawArgs as string[], ...(cwd ? { cwd } : {}) });
|
|
205
|
+
return { toolCallId: context.toolCallId, name: "work_exec", value: { exitCode: result.exitCode, untrusted: true } };
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
208
|
+
return { toolCallId: context.toolCallId, name: "work_exec", content: [{ type: "text", text: message }], error: { message } };
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-pinned work sandbox image. The 64-zero digest is a fixture placeholder —
|
|
3
|
+
* operators replace it with `docker image inspect --format '{{.Id}}'` after build.
|
|
4
|
+
* `createDockerSandbox` requires `name@sha256:<64-hex>` and never pulls.
|
|
5
|
+
*/
|
|
6
|
+
export const WORK_SANDBOX_IMAGE = "prism-work-sandbox@sha256:0000000000000000000000000000000000000000000000000000000000000000";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
assertWorkSandboxEnv,
|
|
3
|
+
createWorkComposition,
|
|
4
|
+
DEFAULT_WORK_SANDBOX_ENV,
|
|
5
|
+
resolveWorkSandboxCapabilities,
|
|
6
|
+
type WorkComposition,
|
|
7
|
+
type WorkCompositionOptions,
|
|
8
|
+
type WorkCompositionResult,
|
|
9
|
+
type WorkSandbox,
|
|
10
|
+
type WorkSandboxCapabilities,
|
|
11
|
+
WorkSandboxError,
|
|
12
|
+
type WorkSandboxExecFileRequest,
|
|
13
|
+
} from "./composition.js";
|
|
14
|
+
export { WORK_SANDBOX_IMAGE } from "./image.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { SheetsCapError, SheetsFormatError, SheetsValidationError } from "./errors.js";
|
|
2
|
+
import type { ResolvedSheetsCaps, SheetsCaps } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// --- Default and Hard Ceiling Constants ---
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_MAX_SHEET_BYTES = 32 * 1024 * 1024; // 32 MiB
|
|
7
|
+
export const HARD_MAX_SHEET_BYTES = 512 * 1024 * 1024; // 512 MiB
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_MAX_SHEETS = 100;
|
|
10
|
+
export const HARD_MAX_SHEETS = 1_000;
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MAX_ROWS = 100_000;
|
|
13
|
+
export const HARD_MAX_ROWS = 1_000_000;
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_MAX_COLUMNS = 1_000;
|
|
16
|
+
export const HARD_MAX_COLUMNS = 16_384;
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_INFERENCE_WINDOW_ROWS = 500;
|
|
19
|
+
export const HARD_INFERENCE_WINDOW_ROWS = 5_000;
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_MAX_WARNINGS = 100;
|
|
22
|
+
export const HARD_MAX_WARNINGS = 1_000;
|
|
23
|
+
|
|
24
|
+
function resolveCap(name: string, value: number | undefined, defaultVal: number, hardCap: number): number {
|
|
25
|
+
if (value === undefined) return defaultVal;
|
|
26
|
+
if (!Number.isInteger(value) || value <= 0 || value > hardCap) {
|
|
27
|
+
throw new SheetsValidationError(`sheets cap ${name} must be an integer in (0, ${hardCap}], got ${value}`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves user-supplied caps against safe defaults and hard ceilings.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveSheetsCaps(caps?: SheetsCaps): ResolvedSheetsCaps {
|
|
36
|
+
return {
|
|
37
|
+
maxBytes: resolveCap("maxBytes", caps?.maxBytes, DEFAULT_MAX_SHEET_BYTES, HARD_MAX_SHEET_BYTES),
|
|
38
|
+
maxSheets: resolveCap("maxSheets", caps?.maxSheets, DEFAULT_MAX_SHEETS, HARD_MAX_SHEETS),
|
|
39
|
+
maxRows: resolveCap("maxRows", caps?.maxRows, DEFAULT_MAX_ROWS, HARD_MAX_ROWS),
|
|
40
|
+
maxColumns: resolveCap("maxColumns", caps?.maxColumns, DEFAULT_MAX_COLUMNS, HARD_MAX_COLUMNS),
|
|
41
|
+
inferenceWindowRows: resolveCap(
|
|
42
|
+
"inferenceWindowRows",
|
|
43
|
+
caps?.inferenceWindowRows,
|
|
44
|
+
DEFAULT_INFERENCE_WINDOW_ROWS,
|
|
45
|
+
HARD_INFERENCE_WINDOW_ROWS,
|
|
46
|
+
),
|
|
47
|
+
maxWarnings: resolveCap("maxWarnings", caps?.maxWarnings, DEFAULT_MAX_WARNINGS, HARD_MAX_WARNINGS),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Enforces byte size ceiling. Throws {@link SheetsCapError} if exceeded.
|
|
53
|
+
*/
|
|
54
|
+
export function validateByteCap(byteLength: number, caps: ResolvedSheetsCaps): void {
|
|
55
|
+
if (byteLength > caps.maxBytes) {
|
|
56
|
+
throw new SheetsCapError(`input size ${byteLength} bytes exceeds maxBytes cap (${caps.maxBytes})`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validates standard PKZIP magic bytes (PK\x03\x04 = 0x50, 0x4b, 0x03, 0x04) at the container boundary.
|
|
62
|
+
* Throws {@link SheetsFormatError} if the buffer does not begin with the ZIP signature.
|
|
63
|
+
*/
|
|
64
|
+
export function validateZipSignature(bytes: Uint8Array): void {
|
|
65
|
+
if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4b || bytes[2] !== 0x03 || bytes[3] !== 0x04) {
|
|
66
|
+
throw new SheetsFormatError("Invalid XLSX container: missing ZIP signature (PK\\x03\\x04)");
|
|
67
|
+
}
|
|
68
|
+
}
|