@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,1205 @@
|
|
|
1
|
+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { access, realpath } from "node:fs/promises";
|
|
5
|
+
import { isAbsolute } from "node:path";
|
|
6
|
+
import { PassThrough } from "node:stream";
|
|
7
|
+
import type { ProcessRecoveryBackend } from "../agent/process/recovery.js";
|
|
8
|
+
import type { ProcessSandboxHandle } from "../agent/process/types.js";
|
|
9
|
+
import {
|
|
10
|
+
assertAbsoluteExecutable,
|
|
11
|
+
createSecretRedactor,
|
|
12
|
+
DockerCliError,
|
|
13
|
+
type DockerRunner,
|
|
14
|
+
dockerOutputText,
|
|
15
|
+
runDockerCli,
|
|
16
|
+
sha256Hex,
|
|
17
|
+
} from "./docker-cli.js";
|
|
18
|
+
import { type EgressAttestation, EgressError } from "./egress/index.js";
|
|
19
|
+
import type {
|
|
20
|
+
DisposableSandbox,
|
|
21
|
+
SandboxCapabilities,
|
|
22
|
+
SandboxCloseOptions,
|
|
23
|
+
SandboxExecFileRequest,
|
|
24
|
+
SandboxExecRequest,
|
|
25
|
+
SandboxExportMetadata,
|
|
26
|
+
SandboxProcessHandle,
|
|
27
|
+
SandboxStatus,
|
|
28
|
+
SandboxStatusState,
|
|
29
|
+
} from "./sandbox.js";
|
|
30
|
+
import { type DockerSandboxLimitOptions, type ResolvedDockerSandboxLimits, resolveDockerSandboxLimits } from "./sandbox-limits.js";
|
|
31
|
+
import { createImportTarStream, SandboxTarError, summarizeTarStream } from "./sandbox-tar.js";
|
|
32
|
+
import { Semaphore } from "./semaphore.js";
|
|
33
|
+
|
|
34
|
+
const IMAGE_DIGEST_RE = /@sha256:[a-f0-9]{64}$/i;
|
|
35
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
36
|
+
const USER_RE = /^[0-9]{1,10}:[0-9]{1,10}$/;
|
|
37
|
+
const SOURCE_MOUNT = "/source";
|
|
38
|
+
const WORKSPACE_MOUNT = "/workspace";
|
|
39
|
+
const TMP_MOUNT = "/tmp";
|
|
40
|
+
const DOWNLOAD_MOUNT = "/downloads";
|
|
41
|
+
const KEEP_ALIVE = ["sleep", "infinity"] as const;
|
|
42
|
+
|
|
43
|
+
export type DockerNetworkConfig =
|
|
44
|
+
| { readonly mode: "none" }
|
|
45
|
+
| {
|
|
46
|
+
readonly mode: "custom";
|
|
47
|
+
/** Pre-created Docker network name. Adapter does not claim DNS/egress containment. */
|
|
48
|
+
readonly name: string;
|
|
49
|
+
/**
|
|
50
|
+
* Optional host attestation that this network forces browser egress through a
|
|
51
|
+
* controlled proxy/firewall. Required before treating custom networks as browse-ready.
|
|
52
|
+
* Playwright routing remains defense in depth only.
|
|
53
|
+
*/
|
|
54
|
+
readonly browserEgress?: {
|
|
55
|
+
readonly proxyEndpoint: string;
|
|
56
|
+
readonly denyDirectEgress: true;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Optional allow-list egress attestation from `EgressProxy.attestation()`.
|
|
60
|
+
* Records proxy enforcement evidence in container labels; the host must
|
|
61
|
+
* actually restrict the network so the proxy is the only reachable path.
|
|
62
|
+
*/
|
|
63
|
+
readonly egress?: EgressAttestation;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Validate an allow-list egress attestation; fails closed on malformed evidence. */
|
|
67
|
+
export function assertEgressAttestation(attestation: EgressAttestation): EgressAttestation {
|
|
68
|
+
if (!attestation || typeof attestation !== "object") {
|
|
69
|
+
throw new EgressError("ERR_PRISM_EGRESS_ATTESTATION", "egress attestation must be an object");
|
|
70
|
+
}
|
|
71
|
+
if (attestation.denyDirectEgress !== true) {
|
|
72
|
+
throw new EgressError("ERR_PRISM_EGRESS_ATTESTATION", "egress attestation requires denyDirectEgress: true");
|
|
73
|
+
}
|
|
74
|
+
if (typeof attestation.policyFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(attestation.policyFingerprint)) {
|
|
75
|
+
throw new EgressError("ERR_PRISM_EGRESS_ATTESTATION", "egress attestation requires a sha256 policyFingerprint");
|
|
76
|
+
}
|
|
77
|
+
if (!Number.isSafeInteger(attestation.policyVersion) || attestation.policyVersion < 0) {
|
|
78
|
+
throw new EgressError("ERR_PRISM_EGRESS_ATTESTATION", "egress attestation requires a non-negative integer policyVersion");
|
|
79
|
+
}
|
|
80
|
+
if (typeof attestation.startedAt !== "string" || Number.isNaN(Date.parse(attestation.startedAt))) {
|
|
81
|
+
throw new EgressError("ERR_PRISM_EGRESS_ATTESTATION", "egress attestation requires a valid startedAt timestamp");
|
|
82
|
+
}
|
|
83
|
+
let proxy: URL;
|
|
84
|
+
try {
|
|
85
|
+
proxy = new URL(attestation.proxyEndpoint);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new EgressError("ERR_PRISM_EGRESS_ATTESTATION", "egress attestation proxyEndpoint is not a valid URL");
|
|
88
|
+
}
|
|
89
|
+
if (proxy.protocol !== "http:" && proxy.protocol !== "https:") {
|
|
90
|
+
throw new EgressError("ERR_PRISM_EGRESS_ATTESTATION", "egress attestation proxyEndpoint must be http(s)");
|
|
91
|
+
}
|
|
92
|
+
return attestation;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Compose a custom sandbox network carrying validated egress attestation. */
|
|
96
|
+
export function composeEgressSandboxNetwork(attestation: EgressAttestation, name: string): DockerNetworkConfig {
|
|
97
|
+
assertEgressAttestation(attestation);
|
|
98
|
+
if (typeof name !== "string" || name.length === 0 || /[\s\\]/.test(name)) {
|
|
99
|
+
throw new DockerSandboxError("custom network requires a non-empty name without whitespace");
|
|
100
|
+
}
|
|
101
|
+
return { mode: "custom", name, egress: attestation };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Fail closed unless the sandbox network is none or carries browser egress attestation. */
|
|
105
|
+
export function assertBrowserSandboxNetwork(network: DockerNetworkConfig | undefined): void {
|
|
106
|
+
const resolved = network ?? { mode: "none" as const };
|
|
107
|
+
if (resolved.mode === "none") return;
|
|
108
|
+
const attestation = resolved.browserEgress;
|
|
109
|
+
if (attestation?.denyDirectEgress !== true || !attestation.proxyEndpoint) {
|
|
110
|
+
throw new DockerSandboxError(
|
|
111
|
+
"custom sandbox network requires browserEgress attestation (proxyEndpoint + denyDirectEgress) before browser use",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const proxy = new URL(attestation.proxyEndpoint);
|
|
116
|
+
if (proxy.protocol !== "http:" && proxy.protocol !== "https:" && proxy.protocol !== "socks5:") {
|
|
117
|
+
throw new DockerSandboxError("browserEgress.proxyEndpoint must be http(s) or socks5");
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error instanceof DockerSandboxError) throw error;
|
|
121
|
+
throw new DockerSandboxError("browserEgress.proxyEndpoint is not a valid URL");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Capabilities established by the validated create options and container
|
|
127
|
+
* construction: a single coherent workspace tree, container filesystem and
|
|
128
|
+
* process namespace, network isolation only for mode `none`, egress
|
|
129
|
+
* restriction only for mode `none` or a custom network carrying a validated
|
|
130
|
+
* egress attestation, and privilege isolation always false for 0.2.0
|
|
131
|
+
* (root-in-container without user namespaces is not a reliable claim).
|
|
132
|
+
*/
|
|
133
|
+
export function resolveDockerCapabilities(network: DockerNetworkConfig): SandboxCapabilities {
|
|
134
|
+
return Object.freeze({
|
|
135
|
+
workspaceCoherent: true,
|
|
136
|
+
filesystemIsolated: true,
|
|
137
|
+
networkIsolated: network.mode === "none",
|
|
138
|
+
processIsolated: true,
|
|
139
|
+
privilegeIsolated: false,
|
|
140
|
+
egressRestricted: network.mode === "none" || (network.mode === "custom" && network.egress !== undefined),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface CreateDockerSandboxOptions {
|
|
145
|
+
/** Absolute path to the host Docker executable. */
|
|
146
|
+
readonly docker: string;
|
|
147
|
+
/** Digest-pinned image reference (`name@sha256:...`). */
|
|
148
|
+
readonly image: string;
|
|
149
|
+
/** Absolute host directory imported into the writable workspace. */
|
|
150
|
+
readonly sourceRoot: string;
|
|
151
|
+
/** Non-root `uid:gid` inside the container. */
|
|
152
|
+
readonly user: string;
|
|
153
|
+
readonly network?: DockerNetworkConfig;
|
|
154
|
+
readonly limits?: DockerSandboxLimitOptions;
|
|
155
|
+
/** Exact environment allow-list. Host environment is never inherited. */
|
|
156
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
157
|
+
/** Secret canaries redacted from CLI errors. */
|
|
158
|
+
readonly secrets?: readonly string[];
|
|
159
|
+
readonly workdir?: string;
|
|
160
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
161
|
+
/** Test seam: replace Docker CLI execution. */
|
|
162
|
+
readonly runner?: DockerRunner;
|
|
163
|
+
/** Skip source import (tests only). */
|
|
164
|
+
readonly skipImport?: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class DockerSandboxError extends Error {
|
|
168
|
+
readonly code = "ERR_PRISM_DOCKER_SANDBOX";
|
|
169
|
+
constructor(message: string) {
|
|
170
|
+
super(message);
|
|
171
|
+
this.name = "DockerSandboxError";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatTmpfs(path: string, sizeBytes: number, mode = "1777"): string {
|
|
176
|
+
return `${path}:rw,nosuid,size=${sizeBytes},mode=${mode}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function validateImage(image: string): string {
|
|
180
|
+
if (!image || typeof image !== "string") {
|
|
181
|
+
throw new DockerSandboxError("image is required");
|
|
182
|
+
}
|
|
183
|
+
if (!IMAGE_DIGEST_RE.test(image)) {
|
|
184
|
+
throw new DockerSandboxError("image must be digest-pinned as name@sha256:<64-hex>");
|
|
185
|
+
}
|
|
186
|
+
return image;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function validateUser(user: string): string {
|
|
190
|
+
if (!USER_RE.test(user)) {
|
|
191
|
+
throw new DockerSandboxError("user must be numeric uid:gid");
|
|
192
|
+
}
|
|
193
|
+
const [uid, gid] = user.split(":").map((part) => Number(part));
|
|
194
|
+
if (uid === 0 || gid === 0) {
|
|
195
|
+
throw new DockerSandboxError("user must be non-root (uid and gid != 0)");
|
|
196
|
+
}
|
|
197
|
+
return user;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function validateEnv(env: Readonly<Record<string, string>> | undefined, limits: ResolvedDockerSandboxLimits): Record<string, string> {
|
|
201
|
+
const entries = Object.entries(env ?? {});
|
|
202
|
+
if (entries.length > limits.maxEnvNames) {
|
|
203
|
+
throw new DockerSandboxError(`env exceeds maxEnvNames (${limits.maxEnvNames})`);
|
|
204
|
+
}
|
|
205
|
+
let bytes = 0;
|
|
206
|
+
const out: Record<string, string> = {};
|
|
207
|
+
for (const [name, value] of entries) {
|
|
208
|
+
if (!ENV_NAME_RE.test(name)) {
|
|
209
|
+
throw new DockerSandboxError(`invalid env name: ${name}`);
|
|
210
|
+
}
|
|
211
|
+
if (typeof value !== "string") {
|
|
212
|
+
throw new DockerSandboxError(`env value for ${name} must be a string`);
|
|
213
|
+
}
|
|
214
|
+
bytes += Buffer.byteLength(name, "utf8") + Buffer.byteLength(value, "utf8");
|
|
215
|
+
if (bytes > limits.maxEnvBytes) {
|
|
216
|
+
throw new DockerSandboxError(`env exceeds maxEnvBytes (${limits.maxEnvBytes})`);
|
|
217
|
+
}
|
|
218
|
+
out[name] = value;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function validateNetwork(network: DockerNetworkConfig | undefined): DockerNetworkConfig {
|
|
224
|
+
if (!network || network.mode === "none") return { mode: "none" };
|
|
225
|
+
if (network.mode !== "custom" || !network.name || /[\s\\]/.test(network.name)) {
|
|
226
|
+
throw new DockerSandboxError("custom network requires a non-empty name without whitespace");
|
|
227
|
+
}
|
|
228
|
+
if (network.egress) assertEgressAttestation(network.egress);
|
|
229
|
+
return network;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function validateSourceRoot(sourceRoot: string): Promise<string> {
|
|
233
|
+
if (!isAbsolute(sourceRoot)) {
|
|
234
|
+
throw new DockerSandboxError("sourceRoot must be an absolute path");
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
await access(sourceRoot, fsConstants.R_OK);
|
|
238
|
+
} catch {
|
|
239
|
+
throw new DockerSandboxError(`sourceRoot is missing or unreadable: ${sourceRoot}`);
|
|
240
|
+
}
|
|
241
|
+
return await realpath(sourceRoot);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildCreateArgs(input: {
|
|
245
|
+
image: string;
|
|
246
|
+
sourceRoot: string;
|
|
247
|
+
user: string;
|
|
248
|
+
network: DockerNetworkConfig;
|
|
249
|
+
limits: ResolvedDockerSandboxLimits;
|
|
250
|
+
env: Record<string, string>;
|
|
251
|
+
workdir: string;
|
|
252
|
+
labels: Record<string, string>;
|
|
253
|
+
}): string[] {
|
|
254
|
+
const args = [
|
|
255
|
+
"create",
|
|
256
|
+
"--pull=never",
|
|
257
|
+
"--read-only",
|
|
258
|
+
"--init",
|
|
259
|
+
"--restart=no",
|
|
260
|
+
`--user=${input.user}`,
|
|
261
|
+
`--workdir=${input.workdir}`,
|
|
262
|
+
`--pids-limit=${input.limits.maxPids}`,
|
|
263
|
+
`--memory=${input.limits.memoryBytes}`,
|
|
264
|
+
`--memory-swap=${input.limits.memoryBytes}`,
|
|
265
|
+
`--cpus=${input.limits.cpus}`,
|
|
266
|
+
"--ulimit",
|
|
267
|
+
`nofile=${input.limits.maxFds}:${input.limits.maxFds}`,
|
|
268
|
+
"--cap-drop=ALL",
|
|
269
|
+
"--security-opt",
|
|
270
|
+
"no-new-privileges=true",
|
|
271
|
+
"--ipc=private",
|
|
272
|
+
`--stop-timeout=${Math.max(1, Math.ceil(input.limits.stopGraceMs / 1000))}`,
|
|
273
|
+
`--mount=type=bind,src=${input.sourceRoot},dst=${SOURCE_MOUNT},readonly`,
|
|
274
|
+
"--tmpfs",
|
|
275
|
+
formatTmpfs(WORKSPACE_MOUNT, input.limits.workspaceBytes, "1777"),
|
|
276
|
+
"--tmpfs",
|
|
277
|
+
formatTmpfs(TMP_MOUNT, input.limits.tmpBytes, "1777"),
|
|
278
|
+
"--tmpfs",
|
|
279
|
+
formatTmpfs(DOWNLOAD_MOUNT, input.limits.downloadBytes, "1777"),
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
if (input.network.mode === "none") args.push("--network=none");
|
|
283
|
+
else {
|
|
284
|
+
args.push(`--network=${input.network.name}`);
|
|
285
|
+
if (input.network.egress) {
|
|
286
|
+
args.push("--label", `prism.egress.endpoint=${input.network.egress.proxyEndpoint}`);
|
|
287
|
+
args.push("--label", `prism.egress.fingerprint=${input.network.egress.policyFingerprint}`);
|
|
288
|
+
args.push("--label", `prism.egress.policyVersion=${input.network.egress.policyVersion}`);
|
|
289
|
+
args.push("--label", "prism.egress.denyDirect=1");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const [key, value] of Object.entries(input.labels)) {
|
|
294
|
+
args.push("--label", `${key}=${value}`);
|
|
295
|
+
}
|
|
296
|
+
for (const [key, value] of Object.entries(input.env)) {
|
|
297
|
+
args.push("--env", `${key}=${value}`);
|
|
298
|
+
}
|
|
299
|
+
args.push(input.image, ...KEEP_ALIVE);
|
|
300
|
+
return args;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function preflightDocker(docker: string, runner: DockerRunner, redact: (text: string) => string, timeoutMs: number): Promise<void> {
|
|
304
|
+
await dockerOutputText(runner, {
|
|
305
|
+
docker,
|
|
306
|
+
args: ["version", "--format", "{{.Server.Version}}"],
|
|
307
|
+
timeoutMs,
|
|
308
|
+
redact,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function importSource(input: {
|
|
313
|
+
docker: string;
|
|
314
|
+
containerId: string;
|
|
315
|
+
user: string;
|
|
316
|
+
sourceRoot: string;
|
|
317
|
+
limits: ResolvedDockerSandboxLimits;
|
|
318
|
+
runner: DockerRunner;
|
|
319
|
+
redact: (text: string) => string;
|
|
320
|
+
}): Promise<SandboxExportMetadata> {
|
|
321
|
+
const bounds = {
|
|
322
|
+
maxEntries: input.limits.maxExportEntries,
|
|
323
|
+
maxBytes: Math.min(input.limits.workspaceBytes, input.limits.maxExportBytes),
|
|
324
|
+
};
|
|
325
|
+
// One FS walk into a capped buffer; hash + docker exec share the same bytes (no second tree walk).
|
|
326
|
+
// ponytail: full import buffered up to maxExportBytes; stream-tee if peak RSS matters.
|
|
327
|
+
const chunks: Buffer[] = [];
|
|
328
|
+
for await (const chunk of createImportTarStream(input.sourceRoot, bounds)) {
|
|
329
|
+
chunks.push(Buffer.from(chunk));
|
|
330
|
+
}
|
|
331
|
+
const buf = Buffer.concat(chunks);
|
|
332
|
+
const summary = await summarizeTarStream(
|
|
333
|
+
(async function* () {
|
|
334
|
+
yield buf;
|
|
335
|
+
})(),
|
|
336
|
+
bounds,
|
|
337
|
+
);
|
|
338
|
+
const result = await input.runner({
|
|
339
|
+
docker: input.docker,
|
|
340
|
+
args: ["exec", "-i", "-u", input.user, "-w", WORKSPACE_MOUNT, input.containerId, "tar", "-xf", "-"],
|
|
341
|
+
stdin: buf,
|
|
342
|
+
timeoutMs: input.limits.startupTimeoutMs,
|
|
343
|
+
maxOutputBytes: 1 * 1024 * 1024,
|
|
344
|
+
redact: input.redact,
|
|
345
|
+
});
|
|
346
|
+
if (result.exitCode !== 0) {
|
|
347
|
+
const detail = result.stderr.toString("utf8").trim() || `exit ${result.exitCode}`;
|
|
348
|
+
throw new DockerSandboxError(input.redact(`workspace import failed: ${detail}`));
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
sha256: summary.sha256,
|
|
352
|
+
entryCount: summary.entryCount,
|
|
353
|
+
byteCount: summary.byteCount,
|
|
354
|
+
format: "tar",
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface DockerProcessRefData {
|
|
359
|
+
readonly version: 1;
|
|
360
|
+
readonly containerId: string;
|
|
361
|
+
readonly processId: string;
|
|
362
|
+
readonly commandFingerprint: string;
|
|
363
|
+
readonly workspace: string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function encodeDockerProcessRef(data: DockerProcessRefData): string {
|
|
367
|
+
const json = JSON.stringify({
|
|
368
|
+
v: 1,
|
|
369
|
+
cid: data.containerId,
|
|
370
|
+
pid: data.processId,
|
|
371
|
+
fp: data.commandFingerprint,
|
|
372
|
+
ws: data.workspace,
|
|
373
|
+
});
|
|
374
|
+
return `prism-docker-proc:${Buffer.from(json, "utf8").toString("base64url")}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function decodeDockerProcessRef(ref: string): DockerProcessRefData | null {
|
|
378
|
+
if (!ref.startsWith("prism-docker-proc:")) return null;
|
|
379
|
+
const raw = ref.slice("prism-docker-proc:".length);
|
|
380
|
+
try {
|
|
381
|
+
const json = Buffer.from(raw, "base64url").toString("utf8");
|
|
382
|
+
const parsed = JSON.parse(json);
|
|
383
|
+
if (
|
|
384
|
+
parsed.v !== 1 ||
|
|
385
|
+
typeof parsed.cid !== "string" ||
|
|
386
|
+
typeof parsed.pid !== "string" ||
|
|
387
|
+
typeof parsed.fp !== "string" ||
|
|
388
|
+
typeof parsed.ws !== "string"
|
|
389
|
+
) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
version: 1,
|
|
394
|
+
containerId: parsed.cid,
|
|
395
|
+
processId: parsed.pid,
|
|
396
|
+
commandFingerprint: parsed.fp,
|
|
397
|
+
workspace: parsed.ws,
|
|
398
|
+
};
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function computeCommandFingerprint(file: string, args: readonly string[] = []): string {
|
|
405
|
+
return sha256Hex(JSON.stringify([file, ...args]));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
class DockerProcessHandle implements SandboxProcessHandle {
|
|
409
|
+
private childProcess: ChildProcessWithoutNullStreams | undefined;
|
|
410
|
+
private readonly stdinStream: PassThrough;
|
|
411
|
+
private readonly abortController: AbortController;
|
|
412
|
+
private _exited = false;
|
|
413
|
+
private _exitCode: number | null = null;
|
|
414
|
+
private _released = false;
|
|
415
|
+
private readonly waiters: Array<() => void> = [];
|
|
416
|
+
readonly ref: string;
|
|
417
|
+
readonly commandFingerprint: string;
|
|
418
|
+
readonly workspace: string;
|
|
419
|
+
readonly processId: string;
|
|
420
|
+
readonly containerId: string;
|
|
421
|
+
|
|
422
|
+
constructor(opts: {
|
|
423
|
+
readonly processId: string;
|
|
424
|
+
readonly containerId: string;
|
|
425
|
+
readonly commandFingerprint: string;
|
|
426
|
+
readonly workspace: string;
|
|
427
|
+
readonly ref: string;
|
|
428
|
+
readonly stdinStream: PassThrough;
|
|
429
|
+
readonly abortController: AbortController;
|
|
430
|
+
}) {
|
|
431
|
+
this.processId = opts.processId;
|
|
432
|
+
this.containerId = opts.containerId;
|
|
433
|
+
this.commandFingerprint = opts.commandFingerprint;
|
|
434
|
+
this.workspace = opts.workspace;
|
|
435
|
+
this.ref = opts.ref;
|
|
436
|
+
this.stdinStream = opts.stdinStream;
|
|
437
|
+
this.abortController = opts.abortController;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
get pid(): number | undefined {
|
|
441
|
+
return this.childProcess?.pid;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
setChild(child: ChildProcessWithoutNullStreams): void {
|
|
445
|
+
this.childProcess = child;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
markExited(exitCode: number | null): void {
|
|
449
|
+
if (this._exited) return;
|
|
450
|
+
this._exited = true;
|
|
451
|
+
this._exitCode = exitCode;
|
|
452
|
+
for (const waiter of this.waiters) waiter();
|
|
453
|
+
this.waiters.length = 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
get isExited(): boolean {
|
|
457
|
+
return this._exited;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async write(data: Uint8Array | string): Promise<void> {
|
|
461
|
+
if (this._exited || this._released) {
|
|
462
|
+
throw new DockerSandboxError("process is not running");
|
|
463
|
+
}
|
|
464
|
+
const buf = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
465
|
+
await new Promise<void>((resolve, reject) => {
|
|
466
|
+
this.stdinStream.write(buf, (err) => {
|
|
467
|
+
if (err) reject(err);
|
|
468
|
+
else resolve();
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async signal(name: NodeJS.Signals | number | string): Promise<void> {
|
|
474
|
+
if (this._exited) {
|
|
475
|
+
throw new DockerSandboxError("process is not running");
|
|
476
|
+
}
|
|
477
|
+
if (name === "SIGKILL" || name === 9) {
|
|
478
|
+
await this.kill();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (this.childProcess) {
|
|
482
|
+
try {
|
|
483
|
+
this.childProcess.kill(name as NodeJS.Signals);
|
|
484
|
+
} catch {
|
|
485
|
+
// best effort
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async kill(): Promise<void> {
|
|
491
|
+
if (this._exited) return;
|
|
492
|
+
this.abortController.abort();
|
|
493
|
+
try {
|
|
494
|
+
this.childProcess?.kill("SIGKILL");
|
|
495
|
+
} catch {
|
|
496
|
+
// ignore
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
this.stdinStream.end();
|
|
500
|
+
} catch {
|
|
501
|
+
// ignore
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async release(): Promise<void> {
|
|
506
|
+
if (this._exited) return;
|
|
507
|
+
this._released = true;
|
|
508
|
+
try {
|
|
509
|
+
this.stdinStream.end();
|
|
510
|
+
} catch {
|
|
511
|
+
// ignore
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
this.childProcess?.unref();
|
|
515
|
+
} catch {
|
|
516
|
+
// ignore
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async wait(waitOptions?: { timeoutMs?: number; signal?: AbortSignal }): Promise<{ exitCode: number | null }> {
|
|
521
|
+
if (this._exited) return { exitCode: this._exitCode };
|
|
522
|
+
return await new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
523
|
+
const onExit = () => {
|
|
524
|
+
cleanup();
|
|
525
|
+
resolve({ exitCode: this._exitCode });
|
|
526
|
+
};
|
|
527
|
+
const onAbort = () => {
|
|
528
|
+
cleanup();
|
|
529
|
+
reject(new DockerSandboxError("wait aborted"));
|
|
530
|
+
};
|
|
531
|
+
let timer: NodeJS.Timeout | undefined;
|
|
532
|
+
const cleanup = () => {
|
|
533
|
+
if (timer) clearTimeout(timer);
|
|
534
|
+
waitOptions?.signal?.removeEventListener("abort", onAbort);
|
|
535
|
+
const idx = this.waiters.indexOf(onExit);
|
|
536
|
+
if (idx >= 0) this.waiters.splice(idx, 1);
|
|
537
|
+
};
|
|
538
|
+
this.waiters.push(onExit);
|
|
539
|
+
if (waitOptions?.timeoutMs !== undefined) {
|
|
540
|
+
timer = setTimeout(() => {
|
|
541
|
+
cleanup();
|
|
542
|
+
reject(new DockerSandboxError(`wait timed out after ${waitOptions.timeoutMs}ms`));
|
|
543
|
+
}, waitOptions.timeoutMs);
|
|
544
|
+
}
|
|
545
|
+
if (waitOptions?.signal) {
|
|
546
|
+
if (waitOptions.signal.aborted) onAbort();
|
|
547
|
+
else waitOptions.signal.addEventListener("abort", onAbort, { once: true });
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
class DockerSandboxSession implements DisposableSandbox {
|
|
554
|
+
readonly id: string;
|
|
555
|
+
readonly capabilities: SandboxCapabilities;
|
|
556
|
+
readonly importIdentity?: SandboxExportMetadata;
|
|
557
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
558
|
+
private _lastExportIdentity: SandboxExportMetadata | undefined;
|
|
559
|
+
private state: SandboxStatusState = "running";
|
|
560
|
+
private commandCount = 0;
|
|
561
|
+
private retainedArtifacts = 0;
|
|
562
|
+
private readonly startedAt = Date.now();
|
|
563
|
+
private lastActivityAt = Date.now();
|
|
564
|
+
private closing: Promise<SandboxExportMetadata | undefined> | undefined;
|
|
565
|
+
private readonly execLock: Semaphore;
|
|
566
|
+
private readonly redact: (text: string) => string;
|
|
567
|
+
private readonly activeProcesses = new Map<string, DockerProcessHandle>();
|
|
568
|
+
|
|
569
|
+
constructor(
|
|
570
|
+
private readonly opts: {
|
|
571
|
+
readonly containerId: string;
|
|
572
|
+
readonly docker: string;
|
|
573
|
+
readonly image: string;
|
|
574
|
+
readonly user: string;
|
|
575
|
+
readonly workdir: string;
|
|
576
|
+
readonly limits: ResolvedDockerSandboxLimits;
|
|
577
|
+
readonly runner: DockerRunner;
|
|
578
|
+
readonly secrets: readonly string[];
|
|
579
|
+
readonly capabilities: SandboxCapabilities;
|
|
580
|
+
readonly importIdentity?: SandboxExportMetadata;
|
|
581
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
582
|
+
},
|
|
583
|
+
) {
|
|
584
|
+
this.id = opts.containerId;
|
|
585
|
+
this.capabilities = opts.capabilities;
|
|
586
|
+
this.importIdentity = opts.importIdentity;
|
|
587
|
+
this.labels = opts.labels;
|
|
588
|
+
this.execLock = new Semaphore(opts.limits.maxConcurrentExecs, DockerSandboxError);
|
|
589
|
+
this.redact = createSecretRedactor(opts.secrets);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
get lastExportIdentity(): SandboxExportMetadata | undefined {
|
|
593
|
+
return this._lastExportIdentity;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private touch(): void {
|
|
597
|
+
this.lastActivityAt = Date.now();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private remainingWallMs(): number {
|
|
601
|
+
return Math.max(1, this.opts.limits.wallTimeMs - (Date.now() - this.startedAt));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private assertActive(): void {
|
|
605
|
+
if (this.state !== "running") throw new DockerSandboxError(`sandbox is ${this.state}`);
|
|
606
|
+
const now = Date.now();
|
|
607
|
+
if (now - this.startedAt > this.opts.limits.wallTimeMs) {
|
|
608
|
+
this.state = "failed";
|
|
609
|
+
throw new DockerSandboxError("sandbox wall time exceeded");
|
|
610
|
+
}
|
|
611
|
+
if (now - this.lastActivityAt > this.opts.limits.idleTimeoutMs) {
|
|
612
|
+
this.state = "failed";
|
|
613
|
+
throw new DockerSandboxError("sandbox idle timeout exceeded");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async status(): Promise<SandboxStatus> {
|
|
618
|
+
return {
|
|
619
|
+
id: this.opts.containerId,
|
|
620
|
+
state: this.state,
|
|
621
|
+
image: this.opts.image,
|
|
622
|
+
startedAt: this.startedAt,
|
|
623
|
+
commandCount: this.commandCount,
|
|
624
|
+
lastActivityAt: this.lastActivityAt,
|
|
625
|
+
...(this.importIdentity ? { importIdentity: this.importIdentity } : {}),
|
|
626
|
+
...(this._lastExportIdentity ? { lastExportIdentity: this._lastExportIdentity } : {}),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async exec(request: SandboxExecRequest): Promise<{ exitCode: number | null }> {
|
|
631
|
+
return this.execFile({
|
|
632
|
+
file: "/bin/sh",
|
|
633
|
+
args: ["-c", request.command],
|
|
634
|
+
cwd: request.cwd,
|
|
635
|
+
env: request.env as Record<string, string> | undefined,
|
|
636
|
+
onData: request.onData,
|
|
637
|
+
signal: request.signal,
|
|
638
|
+
timeout: request.timeout,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async execFile(request: SandboxExecFileRequest): Promise<{ exitCode: number | null }> {
|
|
643
|
+
this.assertActive();
|
|
644
|
+
if (!request.file || request.file.includes("\0")) {
|
|
645
|
+
throw new DockerSandboxError("execFile requires a non-empty file path");
|
|
646
|
+
}
|
|
647
|
+
if (!Array.isArray(request.args) || request.args.some((a) => typeof a !== "string" || a.includes("\0"))) {
|
|
648
|
+
throw new DockerSandboxError("execFile args must be a string array without NUL");
|
|
649
|
+
}
|
|
650
|
+
if (this.commandCount >= this.opts.limits.maxCommands) {
|
|
651
|
+
throw new DockerSandboxError(`sandbox exceeded maxCommands (${this.opts.limits.maxCommands})`);
|
|
652
|
+
}
|
|
653
|
+
const release = await this.execLock.acquire(request.signal);
|
|
654
|
+
this.commandCount += 1;
|
|
655
|
+
this.touch();
|
|
656
|
+
try {
|
|
657
|
+
this.assertActive();
|
|
658
|
+
const cwd = request.cwd ?? this.opts.workdir;
|
|
659
|
+
if (!cwd.startsWith("/") || cwd.includes("\0")) {
|
|
660
|
+
throw new DockerSandboxError("cwd must be an absolute container path");
|
|
661
|
+
}
|
|
662
|
+
const args = ["exec", "-u", this.opts.user, "-w", cwd];
|
|
663
|
+
const extraEnv = validateEnv(request.env, this.opts.limits);
|
|
664
|
+
for (const [key, value] of Object.entries(extraEnv)) {
|
|
665
|
+
args.push("-e", `${key}=${value}`);
|
|
666
|
+
}
|
|
667
|
+
args.push(this.opts.containerId, request.file, ...request.args);
|
|
668
|
+
const timeoutMs = request.timeout !== undefined ? Math.min(request.timeout, this.remainingWallMs()) : this.remainingWallMs();
|
|
669
|
+
let outputBytes = 0;
|
|
670
|
+
const result = await this.opts.runner({
|
|
671
|
+
docker: this.opts.docker,
|
|
672
|
+
args,
|
|
673
|
+
signal: request.signal,
|
|
674
|
+
timeoutMs,
|
|
675
|
+
maxOutputBytes: this.opts.limits.maxOutputBytes,
|
|
676
|
+
collectStdout: false,
|
|
677
|
+
collectStderr: false,
|
|
678
|
+
redact: this.redact,
|
|
679
|
+
onData: (chunk) => {
|
|
680
|
+
outputBytes += chunk.byteLength;
|
|
681
|
+
if (outputBytes > this.opts.limits.maxOutputBytes) {
|
|
682
|
+
throw new DockerSandboxError(`command output exceeded maxOutputBytes (${this.opts.limits.maxOutputBytes})`);
|
|
683
|
+
}
|
|
684
|
+
request.onData?.(chunk);
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
this.touch();
|
|
688
|
+
return { exitCode: result.exitCode };
|
|
689
|
+
} catch (error) {
|
|
690
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
691
|
+
throw new DockerSandboxError(this.redact(message));
|
|
692
|
+
} finally {
|
|
693
|
+
release();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async startProcess(request: SandboxExecFileRequest): Promise<SandboxProcessHandle> {
|
|
698
|
+
this.assertActive();
|
|
699
|
+
const file = request.file ?? (request as unknown as { command?: string }).command;
|
|
700
|
+
if (!file || file.includes("\0")) {
|
|
701
|
+
throw new DockerSandboxError("startProcess requires a non-empty file path");
|
|
702
|
+
}
|
|
703
|
+
const args = request.args ?? [];
|
|
704
|
+
if (!Array.isArray(args) || args.some((a) => typeof a !== "string" || a.includes("\0"))) {
|
|
705
|
+
throw new DockerSandboxError("startProcess args must be a string array without NUL");
|
|
706
|
+
}
|
|
707
|
+
if (this.commandCount >= this.opts.limits.maxCommands) {
|
|
708
|
+
throw new DockerSandboxError(`sandbox exceeded maxCommands (${this.opts.limits.maxCommands})`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const releaseLock = await this.execLock.acquire(request.signal);
|
|
712
|
+
this.commandCount += 1;
|
|
713
|
+
this.touch();
|
|
714
|
+
|
|
715
|
+
let lockReleased = false;
|
|
716
|
+
const releaseLockOnce = () => {
|
|
717
|
+
if (!lockReleased) {
|
|
718
|
+
lockReleased = true;
|
|
719
|
+
releaseLock();
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
this.assertActive();
|
|
725
|
+
const cwd = request.cwd ?? this.opts.workdir;
|
|
726
|
+
if (!cwd.startsWith("/") || cwd.includes("\0")) {
|
|
727
|
+
throw new DockerSandboxError("cwd must be an absolute container path");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const execArgs = ["exec", "-i", "-u", this.opts.user, "-w", cwd];
|
|
731
|
+
const extraEnv = validateEnv(request.env, this.opts.limits);
|
|
732
|
+
for (const [key, value] of Object.entries(extraEnv)) {
|
|
733
|
+
execArgs.push("-e", `${key}=${value}`);
|
|
734
|
+
}
|
|
735
|
+
execArgs.push(this.opts.containerId, file, ...args);
|
|
736
|
+
|
|
737
|
+
const processId = randomUUID();
|
|
738
|
+
const commandFingerprint = computeCommandFingerprint(file, args);
|
|
739
|
+
const ref = encodeDockerProcessRef({
|
|
740
|
+
version: 1,
|
|
741
|
+
containerId: this.opts.containerId,
|
|
742
|
+
processId,
|
|
743
|
+
commandFingerprint,
|
|
744
|
+
workspace: this.opts.workdir,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const stdinStream = new PassThrough();
|
|
748
|
+
const abortController = new AbortController();
|
|
749
|
+
|
|
750
|
+
const combinedSignal = request.signal ? AbortSignal.any([request.signal, abortController.signal]) : abortController.signal;
|
|
751
|
+
|
|
752
|
+
const handle = new DockerProcessHandle({
|
|
753
|
+
processId,
|
|
754
|
+
containerId: this.opts.containerId,
|
|
755
|
+
commandFingerprint,
|
|
756
|
+
workspace: this.opts.workdir,
|
|
757
|
+
ref,
|
|
758
|
+
stdinStream,
|
|
759
|
+
abortController,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
this.activeProcesses.set(processId, handle);
|
|
763
|
+
|
|
764
|
+
const timeoutMs = request.timeout !== undefined ? Math.min(request.timeout, this.remainingWallMs()) : this.remainingWallMs();
|
|
765
|
+
|
|
766
|
+
let outputBytes = 0;
|
|
767
|
+
let spawned = false;
|
|
768
|
+
let resolveSpawn!: () => void;
|
|
769
|
+
let rejectSpawn!: (err: unknown) => void;
|
|
770
|
+
const spawnPromise = new Promise<void>((resolve, reject) => {
|
|
771
|
+
resolveSpawn = () => {
|
|
772
|
+
if (!spawned) {
|
|
773
|
+
spawned = true;
|
|
774
|
+
resolve();
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
rejectSpawn = (err) => {
|
|
778
|
+
if (!spawned) {
|
|
779
|
+
spawned = true;
|
|
780
|
+
reject(err);
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
const fallbackTimer = setImmediate(() => {
|
|
786
|
+
resolveSpawn();
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
const runPromise = this.opts.runner({
|
|
790
|
+
docker: this.opts.docker,
|
|
791
|
+
args: execArgs,
|
|
792
|
+
stdin: stdinStream,
|
|
793
|
+
signal: combinedSignal,
|
|
794
|
+
timeoutMs,
|
|
795
|
+
maxOutputBytes: this.opts.limits.maxOutputBytes,
|
|
796
|
+
collectStdout: false,
|
|
797
|
+
collectStderr: false,
|
|
798
|
+
redact: this.redact,
|
|
799
|
+
onSpawn: (child) => {
|
|
800
|
+
clearImmediate(fallbackTimer);
|
|
801
|
+
handle.setChild(child);
|
|
802
|
+
resolveSpawn();
|
|
803
|
+
},
|
|
804
|
+
onData: (chunk) => {
|
|
805
|
+
outputBytes += chunk.byteLength;
|
|
806
|
+
if (outputBytes > this.opts.limits.maxOutputBytes) {
|
|
807
|
+
handle.kill().catch(() => undefined);
|
|
808
|
+
throw new DockerSandboxError(`command output exceeded maxOutputBytes (${this.opts.limits.maxOutputBytes})`);
|
|
809
|
+
}
|
|
810
|
+
request.onData?.(chunk);
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
runPromise.then(
|
|
815
|
+
(result) => {
|
|
816
|
+
clearImmediate(fallbackTimer);
|
|
817
|
+
resolveSpawn();
|
|
818
|
+
this.activeProcesses.delete(processId);
|
|
819
|
+
releaseLockOnce();
|
|
820
|
+
this.touch();
|
|
821
|
+
handle.markExited(result.exitCode);
|
|
822
|
+
},
|
|
823
|
+
(error) => {
|
|
824
|
+
clearImmediate(fallbackTimer);
|
|
825
|
+
rejectSpawn(error);
|
|
826
|
+
this.activeProcesses.delete(processId);
|
|
827
|
+
releaseLockOnce();
|
|
828
|
+
this.touch();
|
|
829
|
+
const exitCode = handle.isExited ? null : 1;
|
|
830
|
+
handle.markExited(exitCode);
|
|
831
|
+
},
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
await spawnPromise;
|
|
835
|
+
return handle;
|
|
836
|
+
} catch (error) {
|
|
837
|
+
releaseLockOnce();
|
|
838
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
839
|
+
throw new DockerSandboxError(this.redact(message));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async attachProcess(ref: string): Promise<SandboxProcessHandle | null> {
|
|
844
|
+
if (this.state !== "running") return null;
|
|
845
|
+
const decoded = decodeDockerProcessRef(ref);
|
|
846
|
+
if (!decoded) return null;
|
|
847
|
+
if (decoded.containerId !== this.opts.containerId) return null;
|
|
848
|
+
if (decoded.workspace !== this.opts.workdir) return null;
|
|
849
|
+
const handle = this.activeProcesses.get(decoded.processId);
|
|
850
|
+
if (!handle || handle.isExited) return null;
|
|
851
|
+
if (handle.ref !== ref) return null;
|
|
852
|
+
return handle;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async stop(options?: { graceMs?: number; signal?: AbortSignal }): Promise<void> {
|
|
856
|
+
for (const proc of this.activeProcesses.values()) {
|
|
857
|
+
await proc.kill().catch(() => undefined);
|
|
858
|
+
}
|
|
859
|
+
this.activeProcesses.clear();
|
|
860
|
+
if (this.state === "removed" || this.state === "stopped") return;
|
|
861
|
+
const graceMs = Math.min(options?.graceMs ?? this.opts.limits.stopGraceMs, this.opts.limits.stopGraceMs);
|
|
862
|
+
const seconds = Math.max(1, Math.ceil(graceMs / 1000));
|
|
863
|
+
const result = await this.opts.runner({
|
|
864
|
+
docker: this.opts.docker,
|
|
865
|
+
args: ["stop", "--time", String(seconds), this.opts.containerId],
|
|
866
|
+
signal: options?.signal,
|
|
867
|
+
timeoutMs: this.opts.limits.cleanupDeadlineMs,
|
|
868
|
+
redact: this.redact,
|
|
869
|
+
});
|
|
870
|
+
if (result.exitCode !== 0) {
|
|
871
|
+
const detail = result.stderr.toString("utf8").trim();
|
|
872
|
+
if (!/no such container/i.test(detail)) {
|
|
873
|
+
throw new DockerSandboxError(this.redact(detail || "docker stop failed"));
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
this.state = "stopped";
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async kill(options?: { signal?: AbortSignal }): Promise<void> {
|
|
880
|
+
for (const proc of this.activeProcesses.values()) {
|
|
881
|
+
await proc.kill().catch(() => undefined);
|
|
882
|
+
}
|
|
883
|
+
this.activeProcesses.clear();
|
|
884
|
+
if (this.state === "removed") return;
|
|
885
|
+
const result = await this.opts.runner({
|
|
886
|
+
docker: this.opts.docker,
|
|
887
|
+
args: ["kill", this.opts.containerId],
|
|
888
|
+
signal: options?.signal,
|
|
889
|
+
timeoutMs: this.opts.limits.cleanupDeadlineMs,
|
|
890
|
+
redact: this.redact,
|
|
891
|
+
});
|
|
892
|
+
if (result.exitCode !== 0) {
|
|
893
|
+
const detail = result.stderr.toString("utf8").trim();
|
|
894
|
+
if (!/no such container|is not running/i.test(detail)) {
|
|
895
|
+
throw new DockerSandboxError(this.redact(detail || "docker kill failed"));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
this.state = "stopped";
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private async remove(signal?: AbortSignal): Promise<void> {
|
|
902
|
+
if (this.state === "removed") return;
|
|
903
|
+
const result = await this.opts.runner({
|
|
904
|
+
docker: this.opts.docker,
|
|
905
|
+
args: ["rm", "-f", this.opts.containerId],
|
|
906
|
+
signal,
|
|
907
|
+
timeoutMs: this.opts.limits.cleanupDeadlineMs,
|
|
908
|
+
redact: this.redact,
|
|
909
|
+
});
|
|
910
|
+
if (result.exitCode !== 0) {
|
|
911
|
+
const detail = result.stderr.toString("utf8").trim();
|
|
912
|
+
if (!/no such container/i.test(detail)) {
|
|
913
|
+
throw new DockerSandboxError(this.redact(`cleanup failed: ${detail || "docker rm failed"}`));
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
this.state = "removed";
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
private async exportTwoPass(write: NonNullable<SandboxCloseOptions["export"]>, signal?: AbortSignal): Promise<SandboxExportMetadata> {
|
|
920
|
+
if (this.retainedArtifacts >= this.opts.limits.maxRetainedArtifacts) {
|
|
921
|
+
throw new DockerSandboxError(`sandbox exceeded maxRetainedArtifacts (${this.opts.limits.maxRetainedArtifacts})`);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const pass1 = new PassThrough();
|
|
925
|
+
const summaryPromise = summarizeTarStream(pass1, {
|
|
926
|
+
maxEntries: this.opts.limits.maxExportEntries,
|
|
927
|
+
maxBytes: this.opts.limits.maxExportBytes,
|
|
928
|
+
});
|
|
929
|
+
const run1 = this.opts
|
|
930
|
+
.runner({
|
|
931
|
+
docker: this.opts.docker,
|
|
932
|
+
args: ["exec", "-u", this.opts.user, "-w", WORKSPACE_MOUNT, this.opts.containerId, "tar", "-cf", "-", "."],
|
|
933
|
+
signal,
|
|
934
|
+
timeoutMs: this.remainingWallMs(),
|
|
935
|
+
maxOutputBytes: this.opts.limits.maxExportBytes,
|
|
936
|
+
collectStdout: false,
|
|
937
|
+
collectStderr: true,
|
|
938
|
+
redact: this.redact,
|
|
939
|
+
onStdout: (chunk) => pass1.write(chunk),
|
|
940
|
+
})
|
|
941
|
+
.then((result) => {
|
|
942
|
+
pass1.end();
|
|
943
|
+
if (result.exitCode !== 0) {
|
|
944
|
+
const detail = result.stderr.toString("utf8").trim() || `exit ${result.exitCode}`;
|
|
945
|
+
throw new DockerSandboxError(this.redact(`export failed: ${detail}`));
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
const summary = await summaryPromise;
|
|
949
|
+
await run1;
|
|
950
|
+
|
|
951
|
+
const metadata: SandboxExportMetadata = {
|
|
952
|
+
sha256: summary.sha256,
|
|
953
|
+
entryCount: summary.entryCount,
|
|
954
|
+
byteCount: summary.byteCount,
|
|
955
|
+
format: "tar",
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const pass2 = new PassThrough();
|
|
959
|
+
const hostStream = new PassThrough();
|
|
960
|
+
const verifyPromise = summarizeTarStream(pass2, {
|
|
961
|
+
maxEntries: this.opts.limits.maxExportEntries,
|
|
962
|
+
maxBytes: this.opts.limits.maxExportBytes,
|
|
963
|
+
});
|
|
964
|
+
pass2.on("data", (chunk: Buffer) => hostStream.write(chunk));
|
|
965
|
+
pass2.on("end", () => hostStream.end());
|
|
966
|
+
pass2.on("error", (error) => hostStream.destroy(error));
|
|
967
|
+
|
|
968
|
+
const hostWrite = write(hostStream, metadata);
|
|
969
|
+
const run2 = this.opts
|
|
970
|
+
.runner({
|
|
971
|
+
docker: this.opts.docker,
|
|
972
|
+
args: ["exec", "-u", this.opts.user, "-w", WORKSPACE_MOUNT, this.opts.containerId, "tar", "-cf", "-", "."],
|
|
973
|
+
signal,
|
|
974
|
+
timeoutMs: this.remainingWallMs(),
|
|
975
|
+
maxOutputBytes: this.opts.limits.maxExportBytes,
|
|
976
|
+
collectStdout: false,
|
|
977
|
+
collectStderr: true,
|
|
978
|
+
redact: this.redact,
|
|
979
|
+
onStdout: (chunk) => pass2.write(chunk),
|
|
980
|
+
})
|
|
981
|
+
.then((result) => {
|
|
982
|
+
pass2.end();
|
|
983
|
+
if (result.exitCode !== 0) {
|
|
984
|
+
const detail = result.stderr.toString("utf8").trim() || `exit ${result.exitCode}`;
|
|
985
|
+
throw new DockerSandboxError(this.redact(`export failed: ${detail}`));
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
const verify = await verifyPromise;
|
|
991
|
+
await run2;
|
|
992
|
+
await hostWrite;
|
|
993
|
+
if (verify.sha256 !== metadata.sha256 || verify.byteCount !== metadata.byteCount) {
|
|
994
|
+
throw new DockerSandboxError("export hash mismatch between passes");
|
|
995
|
+
}
|
|
996
|
+
this.retainedArtifacts += 1;
|
|
997
|
+
this._lastExportIdentity = metadata;
|
|
998
|
+
this.touch();
|
|
999
|
+
return metadata;
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
hostStream.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
1002
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1003
|
+
throw new DockerSandboxError(this.redact(message));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async close(options?: SandboxCloseOptions): Promise<SandboxExportMetadata | undefined> {
|
|
1008
|
+
if (this.closing) return this.closing;
|
|
1009
|
+
this.closing = (async () => {
|
|
1010
|
+
let metadata: SandboxExportMetadata | undefined;
|
|
1011
|
+
try {
|
|
1012
|
+
if (options?.export && this.state === "running") {
|
|
1013
|
+
metadata = await this.exportTwoPass(options.export, options.signal);
|
|
1014
|
+
}
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
try {
|
|
1017
|
+
await this.stop({ signal: options?.signal });
|
|
1018
|
+
} catch {
|
|
1019
|
+
await this.kill({ signal: options?.signal }).catch(() => undefined);
|
|
1020
|
+
}
|
|
1021
|
+
await this.remove(options?.signal).catch(() => undefined);
|
|
1022
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1023
|
+
throw new DockerSandboxError(this.redact(message));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
try {
|
|
1027
|
+
await this.stop({ signal: options?.signal });
|
|
1028
|
+
} catch {
|
|
1029
|
+
await this.kill({ signal: options?.signal });
|
|
1030
|
+
}
|
|
1031
|
+
await this.remove(options?.signal);
|
|
1032
|
+
return metadata;
|
|
1033
|
+
})();
|
|
1034
|
+
return this.closing;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export async function createDockerSandbox(options: CreateDockerSandboxOptions): Promise<DisposableSandbox> {
|
|
1039
|
+
const docker = await assertAbsoluteExecutable(options.docker, "docker");
|
|
1040
|
+
const image = validateImage(options.image);
|
|
1041
|
+
const user = validateUser(options.user);
|
|
1042
|
+
const limits = resolveDockerSandboxLimits(options.limits);
|
|
1043
|
+
const env = validateEnv(options.env, limits);
|
|
1044
|
+
const network = validateNetwork(options.network);
|
|
1045
|
+
const sourceRoot = await validateSourceRoot(options.sourceRoot);
|
|
1046
|
+
const workdir = options.workdir ?? WORKSPACE_MOUNT;
|
|
1047
|
+
if (!workdir.startsWith("/")) {
|
|
1048
|
+
throw new DockerSandboxError("workdir must be an absolute container path");
|
|
1049
|
+
}
|
|
1050
|
+
const runner = options.runner ?? runDockerCli;
|
|
1051
|
+
const secrets = options.secrets ?? [];
|
|
1052
|
+
const redact = createSecretRedactor(secrets);
|
|
1053
|
+
const sandboxKey = randomUUID();
|
|
1054
|
+
const labels: Record<string, string> = {
|
|
1055
|
+
"prism.sandbox": "1",
|
|
1056
|
+
"prism.sandbox.id": sandboxKey,
|
|
1057
|
+
...(options.labels ?? {}),
|
|
1058
|
+
};
|
|
1059
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
1060
|
+
if (!key || /[\s=]/.test(key) || value.includes("\n")) {
|
|
1061
|
+
throw new DockerSandboxError("invalid sandbox label");
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
await preflightDocker(docker, runner, redact, limits.startupTimeoutMs);
|
|
1066
|
+
|
|
1067
|
+
const createArgs = buildCreateArgs({
|
|
1068
|
+
image,
|
|
1069
|
+
sourceRoot,
|
|
1070
|
+
user,
|
|
1071
|
+
network,
|
|
1072
|
+
limits,
|
|
1073
|
+
env,
|
|
1074
|
+
workdir,
|
|
1075
|
+
labels,
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
let containerId = "";
|
|
1079
|
+
try {
|
|
1080
|
+
containerId = (
|
|
1081
|
+
await dockerOutputText(runner, {
|
|
1082
|
+
docker,
|
|
1083
|
+
args: createArgs,
|
|
1084
|
+
timeoutMs: limits.startupTimeoutMs,
|
|
1085
|
+
redact,
|
|
1086
|
+
})
|
|
1087
|
+
).trim();
|
|
1088
|
+
if (!/^[a-f0-9]{12,64}$/i.test(containerId)) {
|
|
1089
|
+
throw new DockerSandboxError("docker create returned an invalid container id");
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const start = await runner({
|
|
1093
|
+
docker,
|
|
1094
|
+
args: ["start", containerId],
|
|
1095
|
+
timeoutMs: limits.startupTimeoutMs,
|
|
1096
|
+
redact,
|
|
1097
|
+
});
|
|
1098
|
+
if (start.exitCode !== 0) {
|
|
1099
|
+
throw new DockerSandboxError(redact(start.stderr.toString("utf8").trim() || "docker start failed"));
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
let importIdentity: SandboxExportMetadata | undefined;
|
|
1103
|
+
if (!options.skipImport) {
|
|
1104
|
+
importIdentity = await importSource({
|
|
1105
|
+
docker,
|
|
1106
|
+
containerId,
|
|
1107
|
+
user,
|
|
1108
|
+
sourceRoot,
|
|
1109
|
+
limits,
|
|
1110
|
+
runner,
|
|
1111
|
+
redact,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
return new DockerSandboxSession({
|
|
1116
|
+
containerId,
|
|
1117
|
+
docker,
|
|
1118
|
+
image,
|
|
1119
|
+
user,
|
|
1120
|
+
workdir,
|
|
1121
|
+
limits,
|
|
1122
|
+
runner,
|
|
1123
|
+
secrets,
|
|
1124
|
+
capabilities: resolveDockerCapabilities(network),
|
|
1125
|
+
importIdentity,
|
|
1126
|
+
labels,
|
|
1127
|
+
});
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
if (containerId) {
|
|
1130
|
+
await runner({
|
|
1131
|
+
docker,
|
|
1132
|
+
args: ["rm", "-f", containerId],
|
|
1133
|
+
timeoutMs: limits.cleanupDeadlineMs,
|
|
1134
|
+
redact,
|
|
1135
|
+
}).catch(() => undefined);
|
|
1136
|
+
}
|
|
1137
|
+
if (error instanceof DockerSandboxError || error instanceof DockerCliError || error instanceof SandboxTarError) {
|
|
1138
|
+
throw new DockerSandboxError(redact(error.message));
|
|
1139
|
+
}
|
|
1140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1141
|
+
throw new DockerSandboxError(redact(message));
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
export interface DockerProcessRecoveryBackendOptions {
|
|
1146
|
+
readonly expectedContainerId?: string;
|
|
1147
|
+
readonly expectedWorkspace?: string;
|
|
1148
|
+
readonly expectedLabels?: Readonly<Record<string, string>>;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
export function createDockerProcessRecoveryBackend(
|
|
1152
|
+
sandbox: DisposableSandbox,
|
|
1153
|
+
options?: DockerProcessRecoveryBackendOptions,
|
|
1154
|
+
): ProcessRecoveryBackend {
|
|
1155
|
+
return {
|
|
1156
|
+
async attach(ref: string): Promise<ProcessSandboxHandle | null> {
|
|
1157
|
+
if (!sandbox || typeof sandbox.attachProcess !== "function") {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
const decoded = decodeDockerProcessRef(ref);
|
|
1161
|
+
if (!decoded) {
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
if (options?.expectedContainerId && decoded.containerId !== options.expectedContainerId) {
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
if (options?.expectedWorkspace && decoded.workspace !== options.expectedWorkspace) {
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
if (options?.expectedLabels && sandbox.labels) {
|
|
1171
|
+
const sandboxLabels = sandbox.labels as Record<string, string>;
|
|
1172
|
+
for (const [key, value] of Object.entries(options.expectedLabels)) {
|
|
1173
|
+
if (sandboxLabels[key] !== value) {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const handle = await sandbox.attachProcess(ref);
|
|
1179
|
+
return (handle as unknown as ProcessSandboxHandle) ?? null;
|
|
1180
|
+
},
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** @internal test helper: build create argv without starting Docker. */
|
|
1185
|
+
export function buildDockerCreateArgsForTest(options: Omit<CreateDockerSandboxOptions, "docker" | "runner" | "skipImport">): string[] {
|
|
1186
|
+
const limits = resolveDockerSandboxLimits(options.limits);
|
|
1187
|
+
const env = validateEnv(options.env, limits);
|
|
1188
|
+
const network = validateNetwork(options.network);
|
|
1189
|
+
const user = validateUser(options.user);
|
|
1190
|
+
const image = validateImage(options.image);
|
|
1191
|
+
return buildCreateArgs({
|
|
1192
|
+
image,
|
|
1193
|
+
sourceRoot: options.sourceRoot,
|
|
1194
|
+
user,
|
|
1195
|
+
network,
|
|
1196
|
+
limits,
|
|
1197
|
+
env,
|
|
1198
|
+
workdir: options.workdir ?? WORKSPACE_MOUNT,
|
|
1199
|
+
labels: {
|
|
1200
|
+
"prism.sandbox": "1",
|
|
1201
|
+
"prism.sandbox.id": "test",
|
|
1202
|
+
...(options.labels ?? {}),
|
|
1203
|
+
},
|
|
1204
|
+
});
|
|
1205
|
+
}
|