@agen-ai/sandbox-docker 0.0.0-stage → 0.4.4

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/dist/datasets/configuration.d.ts +10 -0
  4. package/dist/datasets/configuration.js +20 -0
  5. package/dist/datasets/mounts.d.ts +21 -0
  6. package/dist/datasets/mounts.js +50 -0
  7. package/dist/datasets/types.d.ts +6 -0
  8. package/dist/datasets/types.js +0 -0
  9. package/dist/datasets/warnings.d.ts +2 -0
  10. package/dist/datasets/warnings.js +7 -0
  11. package/dist/engine/client.d.ts +14 -0
  12. package/dist/engine/client.js +164 -0
  13. package/dist/engine/contracts.d.ts +40 -0
  14. package/dist/engine/contracts.js +22 -0
  15. package/dist/engine/response.d.ts +18 -0
  16. package/dist/engine/response.js +109 -0
  17. package/dist/engine/stream.d.ts +3 -0
  18. package/dist/engine/stream.js +134 -0
  19. package/dist/engine/upgrade.d.ts +9 -0
  20. package/dist/engine/upgrade.js +164 -0
  21. package/dist/engine/upload.d.ts +4 -0
  22. package/dist/engine/upload.js +48 -0
  23. package/dist/index.d.ts +7 -0
  24. package/dist/index.js +30 -0
  25. package/dist/policy/config.d.ts +22 -0
  26. package/dist/policy/config.js +63 -0
  27. package/dist/policy/container.d.ts +70 -0
  28. package/dist/policy/container.js +81 -0
  29. package/dist/policy/preflight.d.ts +9 -0
  30. package/dist/policy/preflight.js +89 -0
  31. package/dist/policy/profiles.d.ts +32 -0
  32. package/dist/policy/profiles.js +44 -0
  33. package/dist/process/attachment.d.ts +19 -0
  34. package/dist/process/attachment.js +109 -0
  35. package/dist/process/framing.d.ts +10 -0
  36. package/dist/process/framing.js +43 -0
  37. package/dist/process/input.d.ts +16 -0
  38. package/dist/process/input.js +62 -0
  39. package/dist/process/output.d.ts +13 -0
  40. package/dist/process/output.js +41 -0
  41. package/dist/process/owner.d.ts +30 -0
  42. package/dist/process/owner.js +129 -0
  43. package/dist/recovery/identity.d.ts +11 -0
  44. package/dist/recovery/identity.js +78 -0
  45. package/dist/recovery/reconcile.d.ts +5 -0
  46. package/dist/recovery/reconcile.js +44 -0
  47. package/dist/resources/api.d.ts +23 -0
  48. package/dist/resources/api.js +70 -0
  49. package/dist/resources/fingerprint.d.ts +3 -0
  50. package/dist/resources/fingerprint.js +94 -0
  51. package/dist/resources/inspect.d.ts +49 -0
  52. package/dist/resources/inspect.js +103 -0
  53. package/dist/resources/owner.d.ts +46 -0
  54. package/dist/resources/owner.js +313 -0
  55. package/dist/workspace/exports.d.ts +5 -0
  56. package/dist/workspace/exports.js +52 -0
  57. package/dist/workspace/imports.d.ts +8 -0
  58. package/dist/workspace/imports.js +49 -0
  59. package/dist/workspace/paths.d.ts +8 -0
  60. package/dist/workspace/paths.js +39 -0
  61. package/dist/workspace/selected.d.ts +5 -0
  62. package/dist/workspace/selected.js +34 -0
  63. package/dist/workspace/stat.d.ts +18 -0
  64. package/dist/workspace/stat.js +63 -0
  65. package/dist/workspace/tar/contracts.d.ts +28 -0
  66. package/dist/workspace/tar/contracts.js +0 -0
  67. package/dist/workspace/tar/decode.d.ts +3 -0
  68. package/dist/workspace/tar/decode.js +62 -0
  69. package/dist/workspace/tar/encode.d.ts +3 -0
  70. package/dist/workspace/tar/encode.js +103 -0
  71. package/dist/workspace/tar/entry.d.ts +6 -0
  72. package/dist/workspace/tar/entry.js +80 -0
  73. package/dist/workspace/tar/header.d.ts +11 -0
  74. package/dist/workspace/tar/header.js +84 -0
  75. package/dist/workspace/tar/pax.d.ts +3 -0
  76. package/dist/workspace/tar/pax.js +40 -0
  77. package/dist/workspace/tar/reader.d.ts +16 -0
  78. package/dist/workspace/tar/reader.js +69 -0
  79. package/package.json +59 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trevor Nichols
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,132 @@
1
- # Temporary Holding Version
1
+ # @agen-ai/sandbox-docker
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Explicit local Docker Engine resources for `@agen-ai/sandbox`. The current 0.4.4 candidate implements bounded provisioning, binary process streams, file import, terminal compute removal, retained file export and cleanup-only recovery. The optional `@agen-ai/sandbox/acp` bridge composes these streams with the public Node ACP provider. This candidate is unpublished.
4
+
5
+ ```ts
6
+ import { createDockerSandboxBackend } from '@agen-ai/sandbox-docker';
7
+
8
+ const backend = createDockerSandboxBackend({
9
+ profile: 'linux-x64', // Optional; this is the default.
10
+ endpoint: 'unix:///var/run/docker.sock',
11
+ namespace: 'my-application',
12
+ spillDirectory: '/absolute/canonical/caller-owned-spill',
13
+ imageDigest: 'sha256:<exact 64 lowercase hexadecimal image ID>',
14
+ user: { uid: 65534, gid: 65534 },
15
+ });
16
+ ```
17
+
18
+ Construction and `backend.reserve(request, recovery)` are synchronous and inert. Startup verifies the explicitly selected spill directory, daemon and existing image before native resource acquisition. No environment-derived endpoint, image pull, image build, tool installation, host environment or Docker credential discovery occurs. The selected image must have no declared volumes; prepare a trusted image separately and provide its exact local image ID. UID and GID must each be nonzero and fit the archive ownership range 1–262143.
19
+
20
+ ## Prepare the guest image
21
+
22
+ Select an already-present Linux image matching the selected profile's image architecture by its exact local `sha256:` image ID. It must contain `/bin/sh`, `sleep` and the chosen agent executable, support the non-root read-only-root profile, and declare no volumes. Every executable inside the image must be usable under that image's native or explicitly selected emulated architecture. A macOS host executable is not a Linux guest executable. The backend overrides the image entrypoint and command with its fixed keeper. Preparation is separate from the SDK's bounded workspace file import.
23
+
24
+ The workload environment must match these fixed values exactly:
25
+
26
+ | Name | Value |
27
+ | --- | --- |
28
+ | `HOME` | `/home/sandbox` |
29
+ | `TMPDIR` | `/tmp` |
30
+ | `PATH` | `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` |
31
+
32
+ Image environment metadata may omit those entries or declare their exact values. Additional image environment entries are rejected by workload readback; the backend does not silently strip or normalize them. An ordinary Node image's `NODE_VERSION` and `YARN_VERSION` metadata therefore requires explicit image preparation. The complete [standalone walkthrough](../../examples/sandbox/README.md) shows the caller-owned preparation helper and credential-free runnable example. The SDK never pulls or builds the image.
33
+
34
+ ## Shared read-only datasets
35
+
36
+ `datasets` explicitly attaches already-present Linux images outside the writable workspace. Each attachment has a unique lowercase `name`, exact local `imageDigest` and optional canonical relative `subpath`:
37
+
38
+ ```ts
39
+ const backend = createDockerSandboxBackend({
40
+ endpoint: 'unix:///var/run/docker.sock',
41
+ namespace: 'evaluation',
42
+ spillDirectory: '/absolute/owned-spill',
43
+ imageDigest: 'sha256:<exact workload image ID>',
44
+ user: { uid: 65534, gid: 65534 },
45
+ datasets: [{ name: 'reference', imageDigest: 'sha256:<exact dataset image ID>', subpath: 'corpus' }],
46
+ });
47
+ ```
48
+
49
+ The guest reads that attachment at `/datasets/reference`. Subpaths are lexically canonical; Docker resolves any stable internal symlink alias and requires the resolved target to stay within the image. This is image confinement, not a blanket no-follow guarantee. Dataset images are data sources, so they need not match the workload's CPU architecture or fixed image environment. Executing a dataset binary still requires a compatible guest runtime. No image is pulled, built or deleted by this attachment API. Each sandbox retains its own private writable `/workspace/work`; workspace exports include only that workspace. The never-started export accessor does not mount datasets.
50
+
51
+ Dataset admission requires stable Docker Engine 29.1.4 or newer and the containerd image-store profile (`Driver: overlayfs`, `driver-type=io.containerd.snapshotter.v1`) in addition to the selected execution profile. An empty or omitted dataset list does not add an image-store requirement. The backend checks exact image identities, requested image mounts and effective read-only mount identities. Engine 29.1.4 marks image mounts experimental; only its exact per-mount advisory is accepted for explicitly selected datasets. Unexpected warnings still reject startup. Read-only dataset images remain borrowed through stop, destroy and cleanup recovery. A finite maximum of 32 attachments bounds mount configuration; it imposes no dataset file-count or aggregate-byte quota. Arbitrary guest targets, host binds and externally mutable named-volume datasets are not admitted. Native Mac dataset qualification remains pending independently of baseline Mac execution.
52
+
53
+ `policy.workspaceDiskQuota` defaults to `{ kind: 'not_requested' }`. Explicit `{ kind: 'required', bytes, accounting: 'allocated_blocks' }` rejects before driver reservation because this backend cannot enforce a qualified hard quota. CPU/RAM/PID controls and optional logical transfer quotas do not protect Docker storage from guest writes. A positive local project-quota adapter is deferred until a suitable native filesystem and enforcement proof exist; no host reconfiguration or unverified quota selector is provided.
54
+
55
+ ## Execution and files
56
+
57
+ `DockerSandboxProfile` is a closed choice of the following compositions. The default `linux-x64` profile preserves the existing Linux admission. Both Desktop profiles are implementation candidates awaiting actual Mac qualification.
58
+
59
+ | Profile | Node controller | Linux daemon | Linux image | cgroup v2 driver | AppArmor |
60
+ | --- | --- | --- | --- | --- | --- |
61
+ | `linux-x64` (default) | Linux x64 | amd64 | amd64 | systemd | daemon enabled; running workload `docker-default` |
62
+ | `desktop-apple-virtualization-native-arm64` | macOS arm64 | arm64, Docker Desktop | arm64 | cgroupfs | daemon absent; running workload profile empty |
63
+ | `desktop-apple-virtualization-rosetta-amd64` | macOS arm64 | arm64, Docker Desktop | amd64 | cgroupfs | daemon absent; running workload profile empty |
64
+
65
+ Every profile requires rootful Engine API 1.52 compatibility, CPU, memory, swap and PID controllers, builtin default seccomp and private cgroup namespaces. Observable rootless/user-namespace modes and mismatched controls reject. Desktop admission checks Engine `OperatingSystem: 'Docker Desktop'`; the daemon hostname is not a security invariant. A Desktop selection on Linux, an Intel Mac or native Windows rejects before spill-directory or Engine access. All profiles retain the same workload resource and isolation readback. Docker controller access remains powerful host authority; the workload never receives the socket or host bind mounts.
66
+
67
+ For an Apple Silicon Mac, select the socket and profile explicitly:
68
+
69
+ ```ts
70
+ const backend = createDockerSandboxBackend({
71
+ profile: 'desktop-apple-virtualization-native-arm64',
72
+ endpoint: 'unix:///absolute/user/.docker/run/docker.sock',
73
+ namespace: 'my-application',
74
+ spillDirectory: '/absolute/owned-sandbox-spill',
75
+ imageDigest: 'sha256:<exact local Linux arm64 image ID>',
76
+ user: { uid: 65534, gid: 65534 },
77
+ });
78
+ ```
79
+
80
+ Choosing a Desktop profile is the caller's explicit assertion that Docker Desktop uses Apple Virtualization framework with Enhanced Container Isolation disabled. The Rosetta profile additionally asserts that Rosetta for Linux amd64 emulation is enabled; it requires an amd64 image on the arm64 daemon. Container creation explicitly requests the selected image's Linux platform. Engine API checks cannot independently establish all those Desktop settings or which emulator executed an instruction. Record and verify them in actual platform qualification. There is no automatic architecture fallback, Docker context discovery, image pull or Desktop reconfiguration. Docker VMM, Intel Macs, ECI, WSL and native Windows have no admitted profile. The Desktop VM is shared infrastructure; effective sandbox isolation remains `container`.
81
+
82
+ The workload has a read-only root, explicit non-root identity, all capabilities dropped, no-new-privileges, private PID/IPC/cgroup namespaces and exact CPU/memory/PID limits. Home and temporary files use bounded tmpfs mounts. One private named volume holds `/workspace/work`; transfer count/byte limits do not establish a workspace disk quota. The fixed `/bin/sh` keeper overrides the image entrypoint and command. Images must support that workload profile.
83
+
84
+ `isolated` selects Docker `none`. Explicit `outbound` creates a private bridge and permits access to reachable host, LAN and internet services. Neither profile provides a domain allowlist or credential secrecy within the guest. No published ports, privileged mode, devices or arbitrary Docker options are admitted.
85
+
86
+ `importTree` streams regular files/directories before process reservation. `exportTree` requires stopped compute and a never-started, read-only accessor of the exact private owned volume. Whole-workspace export explicitly streams `/workspace`; exact-file and single-subtree selections check every ancestor with bounded HEAD operations, then GET only the selected file or directory. `{ kind: 'subtree', path: 'artifacts' }` includes that directory and its descendants, preserving workspace-relative paths. The selected root must be a real directory; unrelated entries are not scanned. No unselected root archive is fetched or filtered. The no-writer ownership condition is mandatory; this mechanism does not admit a mutable borrowed workspace.
87
+
88
+ Archive headers, checksums, local PAX metadata, body lengths, padding and complete termination validate incrementally. Link archive records, special/sparse files, traversal, duplicate paths and conflicting file ancestors reject. A selected regular file may be a hardlink alias within the same private volume: Engine stat has no link count, and a one-file tar emits it as regular bytes. This does not permit symlink traversal. Callback bytes remain provisional until complete operation success.
89
+
90
+ The initial archive representation supports individual sizes through `8 GiB - 1`; base-256 and PAX size overrides are unsupported. This format boundary is separate from optional caller quotas. Aggregate workspace bytes and file count have no universal SDK ceiling. Streaming HTTP, bounded chunks and external metadata sorting replace complete-body buffers. Connection/header/acknowledgment observations are finite, but no implicit timer covers the complete body; upload acknowledgment begins after the body finishes. Control JSON remains bounded and buffered. `spillDirectory` is explicit host authority; no default home/temp path is discovered.
91
+
92
+ Reserve one process with an absolute guest executable, literal arguments, explicit environment and the returned guest workspace. Docker exec uses the selected non-root user, no TTY and separate binary stdout/stderr. The backend supplies the fixed HOME, TMPDIR and PATH above; conflicting overrides fail during inert reservation. Other explicit process environment values are passed literally. Credentials supplied in that environment are readable by guest code and the trusted Docker controller. Image preparation and credential selection remain the caller's responsibility.
93
+
94
+ The process exit receipt describes the leader only. Descendants and the keeper remain owned until `stop()` removes the whole workload. If compute removal prevents observing leader status, exit reports `unavailable` with reason `sandbox_stopped`. An observed exit receipt never changes. Stdin supports backpressure and half-close; abandoning an output reader, malformed framing, output overflow or an explicitly selected execution deadline initiates whole-sandbox stop. Startup and cleanup do not replay exec requests.
95
+
96
+ Sandbox `policy.execution` defaults to `{ kind: 'until_stopped' }`, which installs no execution or idle timer. Select `{ kind: 'deadline', timeoutMs }` explicitly for a finite process lifetime; positive safe-integer millisecond values support days and durations beyond Node's single-timer range. CPU, memory, PID and working buffers remain independent. `deadlines.startupMs` and `cleanupMs` govern infrastructure observations; each transfer can opt into `deadlineAt` and logical quotas without a default transfer lifetime. See the [neutral policy contract](../sandbox/README.md#explicit-policy-and-finite-values) for timing scope and migration from the retired `deadlines.executionMs` field.
97
+
98
+ Docker can acknowledge the stream upgrade before the exec starts. A non-running exec with a null exit code remains pending under the selected execution policy; only a non-running exec with a numeric exit code yields an exit receipt.
99
+
100
+ `maxBufferedOutputBytes` bounds the combined public stdout/stderr staging. Docker additionally bounds its accepted upgrade head to 64 KiB, current receive chunk to 64 KiB, partial frame header to 8 bytes and each raw output channel to one selected-high-water-mark chunk (at most 64 KiB each). This permits at most 256 KiB + 8 bytes of additional library-owned framing/output staging at maximum settings, plus Node socket/kernel buffers and object metadata. Frame declarations above 1 MiB fail before payload retention; a complete frame is never allocated solely from that declaration. These are staging bounds, not a total heap cap.
101
+
102
+ `stop()` permanently force-removes the exact workload container ID and retains the volume and accessor. `destroy()` additionally removes the exact accessor, volume and optional network, and can be retried after partial failure. There is no broad prune, label scan, automatic command replay or known-ID fallback to a name. Startup cancellation joins entered acquisition. The original owner can complete cleanup when cancellation or failed persistence prevented a create from being dispatched. A dispatched create with an unresolved outcome, or a recovered `creating` checkpoint, remains uncertain when its resource name is absent.
103
+
104
+ The optional `@agen-ai/sandbox/acp` bridge uses this one process for one initialized native connection and one SDK session, which can run multiple turns. Provider materialization is eager and can start the process before session opening. Retain and dispose the registry or instance even if opening never succeeds. Connection release stops the whole compute workload and joins its streams while retaining files. Close the SDK, export selected artifacts, then destroy retained resources. The bridge disables host facilities; supplying additional host callbacks, MCP servers or runtime host bindings separately grants the caller's own host authority. The [example lifecycle helper](../../examples/sandbox/session.ts) implements this ownership sequence and retains exact cleanup retry handles; it is example source, not a package export.
105
+
106
+ ## Cleanup-only recovery
107
+
108
+ Recovery records carry fixed non-secret operation, profile, daemon, image and resource identities. Backend revisions are `engine-1.52-v4-<profile>` and recovery v2 binds the normalized profile, non-root user, workload image and dataset attachments in `configurationDigest`; `policyDigest` binds request policy separately. Earlier revisions and other selected profiles conflict even for reserved records, before Engine access. Persisted checkpoints precede resource effects and record inspected IDs before later effects. Resource labels bind the complete original immutable record, and reconciliation checks exact names, labels and configuration fingerprints against the explicit profile/namespace/image/daemon. Daemon admission is repeated before resource operations and cleanup; a changed profile or daemon retains cleanup ownership without acting on resources. Reconciliation returns cleanup ownership only. A planned resource is never adopted; unknown acquisition outcomes retain their retry owner.
109
+
110
+ Recover from the latest durable record only after the previous controller and its entered operations are quiescent. A stale planned record conflicts when its exact resource name is already present, including when that name belongs to a foreign resource. If reconciliation advances a `creating` identity to an inspected ID, persist the returned `owner.recoveryRecord` before invoking cleanup; reconciliation does not write to the caller's persistence store.
111
+
112
+ ## Qualification scope
113
+
114
+ Real backend and installed-package evidence covers Linux x86_64, Node 24.18.0, rootful Docker Engine 29.1.3 through API 1.52, cgroup v2/systemd, default seccomp, AppArmor and private cgroup namespaces. It includes descendant containment, memory/PID enforcement, stream limits, cancellation, lost Engine replies, exact-resource cleanup and fresh-controller cleanup recovery. Controller death does not stop compute automatically; recovery restores cleanup ownership, never a runnable process or session. Other unrecorded platform/daemon tuples and storage or power-loss recovery remain unqualified.
115
+
116
+ A separate shared-dataset campaign passes on the same Linux/Node/kernel tuple using official Docker Engine 29.1.4 (`08440b6`), containerd 2.2.2, runc 1.3.4 and the overlayfs containerd image store. Six native source scenarios and the installed public dataset example prove exact shared content, actual read-only mounts, private writable workspaces, sibling survival, specific image-path confinement, unsupported-quota rejection and cleanup recovery. Seven independently observed compute scopes are empty after cleanup. This qualifies the recorded dataset composition; mount identity does not measure physical block deduplication. The original 29.1.3 baseline remains independently qualified.
117
+
118
+ Synthetic hermetic tests exercise both Desktop admission variants, incompatible controllers, image/daemon architecture mismatches, missing controllers/security controls and profile-bound recovery. These tests do not run Docker Desktop, arm64 guest code or Rosetta. Actual Apple Silicon native arm64 and the required AVF/Rosetta amd64 harness rows remain pending the separate Mac handoff. An independently successful native arm64 row would not qualify an amd64-only harness.
119
+
120
+ The retained pre-streaming installed native composition used matching ten-package `0.4.4` candidate artifacts, the ordinary public Node ACP constructor and sandbox bridge, and Grok Build 1.0.62 revision `1474f9cbdd91`. Its Linux amd64 binary SHA-256 was `2a46d9e4d7ebd7a9cff7f95716714b19dad5f21dd2a497822400b1fb128969aa`. With xAI API-key authentication it requested `grok-4.3` and low reasoning. The initialized catalog did not independently identify the backend model; command arguments establish the requested selection only. Those source and tarball identities remain historical evidence; migrating the tooling to streaming transfers records no new model qualification.
121
+
122
+ Four portable turns covered an exact file task and follow-up in one session, the same first task in fresh isolated compute, storage, home and temporary state, and cancellation after a nonempty native content delta. Six once-only approval responses were delivered. Exact exported bytes matched expectations; each successful SDK close had independently observed compute absence before export, followed by exact retained-resource absence after destruction. This evidence does not qualify other providers or versions, subscription authentication, fleet execution, backend billing, structured output or execution input. Their independent qualification scopes remain unchanged.
123
+
124
+ A later qualification-controller signal/cancellation repair was verified by a real local child-process regression and a fresh credential-free installed initialization. SDK runtime payloads, native configuration and public examples matched the original four-turn campaign. Authenticated inference was not replayed; those four turns retain their original launcher identity. Hosted Docker CI is configured but has no recorded hosted execution result.
125
+
126
+ ## Run backend checks
127
+
128
+ Hermetic tests run with `pnpm --filter @agen-ai/sandbox-docker test`. The separately named `test:integration` lane requires explicit `AGENAI_SANDBOX_TEST_ENGINE`, `AGENAI_SANDBOX_TEST_MANIFEST` and `AGENAI_SANDBOX_TEST_OUTPUT` values. Each suite owns a separate child of that fresh output directory. The fixture manifest selects a previously prepared image containing `/usr/local/bin/node` at the recorded version, plus the existing shell utilities. It has no provider/model credentials and makes no inference call. Real-engine evidence establishes only its recorded host/image/configuration tuple.
129
+
130
+ The integration lane exercises bounded memory/PID exhaustion inside owned containers, lost Engine replies through owned Unix proxies and exact child-controller death. Its independent Linux observer needs read access to the workload keeper's `/proc/<pid>/cgroup` and corresponding `/sys/fs/cgroup` files. It never restarts the shared daemon or performs a broad resource sweep. Reports preserve original failures separately from later cleanup. A fresh process can recover both an identified running workload and a verified resource left in `creating`; the caller must persist any advanced record before cleanup. This qualifies process-crash recovery on the recorded local tuple, not live execution resumption or recovery from storage/power loss.
131
+
132
+ Recovery uses `agenai.sandbox-recovery.v2` and `engine-1.52-v4-<profile>`. Its immutable `configurationDigest` binds the normalized profile, non-root identity, workload image and sorted dataset configuration separately from `policyDigest`. Changed or legacy configuration cannot adopt a record before Engine effects. Borrowed dataset images never become owned deletion resources, and exact owned-container cleanup does not require the dataset image to remain available.
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod/v4';
2
+ import type { DockerSandboxDataset } from './types.js';
3
+ export declare const MAX_DATASET_ATTACHMENTS = 32;
4
+ export declare const datasetsSchema: z.ZodArray<z.ZodObject<{
5
+ name: z.ZodString;
6
+ imageDigest: z.ZodString;
7
+ subpath: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$strict>>;
9
+ export declare function normalizeDatasets(entries: readonly DockerSandboxDataset[]): readonly DockerSandboxDataset[];
10
+ //# sourceMappingURL=configuration.d.ts.map
@@ -0,0 +1,20 @@
1
+ import { z } from "zod/v4";
2
+ import { parseSandboxRelativePath } from "@agen-ai/sandbox";
3
+ const MAX_DATASET_ATTACHMENTS = 32;
4
+ const datasetsSchema = z.array(z.strictObject({
5
+ name: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/u),
6
+ imageDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
7
+ subpath: z.string().max(1024).optional()
8
+ })).max(MAX_DATASET_ATTACHMENTS).refine((entries) => new Set(entries.map(({ name }) => name)).size === entries.length, "Dataset names must be unique");
9
+ function normalizeDatasets(entries) {
10
+ return Object.freeze(entries.map((entry) => Object.freeze({
11
+ name: entry.name,
12
+ imageDigest: entry.imageDigest,
13
+ ...entry.subpath === void 0 ? {} : { subpath: parseSandboxRelativePath(entry.subpath) }
14
+ })).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
15
+ }
16
+ export {
17
+ MAX_DATASET_ATTACHMENTS,
18
+ datasetsSchema,
19
+ normalizeDatasets
20
+ };
@@ -0,0 +1,21 @@
1
+ import type { ContainerInspection } from '../resources/inspect.js';
2
+ import type { DockerSandboxDataset } from './types.js';
3
+ export declare function containerMounts(datasets: readonly DockerSandboxDataset[], role: 'compute' | 'export', storageId: string): ({
4
+ ImageOptions?: {
5
+ Subpath: string;
6
+ } | undefined;
7
+ Type: "image";
8
+ Source: string;
9
+ Target: string;
10
+ ReadOnly: true;
11
+ } | {
12
+ Type: "volume";
13
+ Source: string;
14
+ Target: string;
15
+ ReadOnly: boolean;
16
+ VolumeOptions: {
17
+ NoCopy: true;
18
+ };
19
+ })[];
20
+ export declare function verifyContainerMounts(container: ContainerInspection, datasets: readonly DockerSandboxDataset[], role: 'compute' | 'export', storageId: string): void;
21
+ //# sourceMappingURL=mounts.d.ts.map
@@ -0,0 +1,50 @@
1
+ import { z } from "zod/v4";
2
+ import { SandboxUnsupportedError } from "@agen-ai/sandbox";
3
+ import { MAX_DATASET_ATTACHMENTS } from "./configuration.js";
4
+ const hostMountSchema = z.discriminatedUnion("Type", [
5
+ z.strictObject({
6
+ Type: z.literal("volume"),
7
+ Source: z.string(),
8
+ Target: z.string(),
9
+ ReadOnly: z.boolean().default(false),
10
+ VolumeOptions: z.strictObject({ NoCopy: z.literal(true) })
11
+ }),
12
+ z.strictObject({
13
+ Type: z.literal("image"),
14
+ Source: z.string(),
15
+ Target: z.string(),
16
+ ReadOnly: z.literal(true),
17
+ ImageOptions: z.strictObject({ Subpath: z.string() }).optional()
18
+ })
19
+ ]);
20
+ const hostMountsSchema = z.array(hostMountSchema).min(1).max(MAX_DATASET_ATTACHMENTS + 1);
21
+ function containerMounts(datasets, role, storageId) {
22
+ return [
23
+ { Type: "volume", Source: storageId, Target: "/workspace", ReadOnly: role === "export", VolumeOptions: { NoCopy: true } },
24
+ ...role === "compute" ? datasets.map((dataset) => ({
25
+ Type: "image",
26
+ Source: dataset.imageDigest,
27
+ Target: `/datasets/${dataset.name}`,
28
+ ReadOnly: true,
29
+ ...dataset.subpath === void 0 ? {} : { ImageOptions: { Subpath: dataset.subpath } }
30
+ })) : []
31
+ ];
32
+ }
33
+ function verifyContainerMounts(container, datasets, role, storageId) {
34
+ const expected = containerMounts(datasets, role, storageId);
35
+ const parsed = hostMountsSchema.safeParse(container.HostConfig.Mounts);
36
+ if (!parsed.success || parsed.data.length !== expected.length || container.Mounts.length !== expected.length) {
37
+ throw new SandboxUnsupportedError("Docker mount inventory differs from the private workspace and selected read-only datasets.");
38
+ }
39
+ for (const specification of expected) {
40
+ const requested = parsed.data.find((mount) => mount.Target === specification.Target);
41
+ const effective = container.Mounts.find((mount) => mount.Destination === specification.Target);
42
+ if (!requested || requested.Type !== specification.Type || requested.Source !== specification.Source || requested.ReadOnly !== specification.ReadOnly || specification.Type === "image" && (requested.Type !== "image" || requested.ImageOptions?.Subpath !== specification.ImageOptions?.Subpath) || !effective || effective.Type !== specification.Type || effective.Name !== specification.Source || effective.RW !== !specification.ReadOnly || specification.Type === "image" && (!effective.Source.startsWith("/") || effective.Driver !== void 0 && effective.Driver !== "" || effective.Propagation !== "rprivate")) {
43
+ throw new SandboxUnsupportedError("Docker mount readback does not match the exact workspace or borrowed read-only image identity.");
44
+ }
45
+ }
46
+ }
47
+ export {
48
+ containerMounts,
49
+ verifyContainerMounts
50
+ };
@@ -0,0 +1,6 @@
1
+ export interface DockerSandboxDataset {
2
+ readonly name: string;
3
+ readonly imageDigest: string;
4
+ readonly subpath?: string;
5
+ }
6
+ //# sourceMappingURL=types.d.ts.map
File without changes
@@ -0,0 +1,2 @@
1
+ export declare function hasUnexpectedImageMountWarnings(warnings: readonly string[], imageMounts: number): boolean;
2
+ //# sourceMappingURL=warnings.d.ts.map
@@ -0,0 +1,7 @@
1
+ const IMAGE_MOUNT_ADVISORY = "Image mount is an experimental feature";
2
+ function hasUnexpectedImageMountWarnings(warnings, imageMounts) {
3
+ return warnings.length > 0 && (warnings.length !== imageMounts || warnings.some((warning) => warning !== IMAGE_MOUNT_ADVISORY));
4
+ }
5
+ export {
6
+ hasUnexpectedImageMountWarnings
7
+ };
@@ -0,0 +1,14 @@
1
+ import { type DockerEngineRequestOptions, type DockerEngineResponse } from './contracts.js';
2
+ import { type DockerEngineAttachment } from './upgrade.js';
3
+ import type { DockerEngineStreamOptions, DockerEngineStreamResponse, DockerEngineStreamResult } from './contracts.js';
4
+ export { DockerEngineTransportError } from './contracts.js';
5
+ export type { DockerEngineRequestOptions, DockerEngineResponse, DockerEngineTransportErrorCode } from './contracts.js';
6
+ export type { DockerEngineAttachment } from './upgrade.js';
7
+ export declare class DockerEngine {
8
+ private readonly socketPath;
9
+ constructor(socketPath: string);
10
+ upgrade(path: string, options: DockerEngineRequestOptions): Promise<DockerEngineAttachment>;
11
+ stream<Value>(method: 'GET' | 'HEAD' | 'PUT', path: string, options: DockerEngineStreamOptions, consume: (response: DockerEngineStreamResponse) => Promise<Value>): Promise<DockerEngineStreamResult<Value>>;
12
+ request(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, options: DockerEngineRequestOptions): Promise<DockerEngineResponse>;
13
+ }
14
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,164 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { performance } from "node:perf_hooks";
3
+ import { DockerEngineTransportError, validateEngineRequest } from "./contracts.js";
4
+ import { upgradeEngineConnection } from "./upgrade.js";
5
+ import { streamEngine } from "./stream.js";
6
+ import { DockerEngineTransportError as DockerEngineTransportError2 } from "./contracts.js";
7
+ class DockerEngine {
8
+ constructor(socketPath) {
9
+ this.socketPath = socketPath;
10
+ if (!socketPath.startsWith("/") || socketPath.startsWith("//") || /[\u0000-\u001f\u007f]/u.test(socketPath)) {
11
+ throw new TypeError("Engine endpoint must be an explicit absolute Unix socket path");
12
+ }
13
+ }
14
+ upgrade(path, options) {
15
+ return upgradeEngineConnection(this.socketPath, path, options);
16
+ }
17
+ stream(method, path, options, consume) {
18
+ return streamEngine(this.socketPath, method, path, options, consume);
19
+ }
20
+ async request(method, path, options) {
21
+ const { timeoutMs, maxResponseBytes, contentType, signal } = options;
22
+ validateEngineRequest(path, options);
23
+ if (signal?.aborted) throw new DockerEngineTransportError("aborted", "Engine request aborted");
24
+ const deadline = performance.now() + timeoutMs;
25
+ const body = options.body === void 0 ? void 0 : Buffer.from(options.body);
26
+ if (performance.now() >= deadline) throw new DockerEngineTransportError("deadline", "Engine request deadline exceeded");
27
+ return new Promise((resolve, reject) => {
28
+ let response;
29
+ let upgradedSocket;
30
+ let requestClosed = false;
31
+ let responseClosed = false;
32
+ let upgradedSocketClosed = false;
33
+ let outcome;
34
+ let settled = false;
35
+ let receivedBytes = 0;
36
+ let expectedBytes;
37
+ const chunks = [];
38
+ const request = httpRequest({
39
+ socketPath: this.socketPath,
40
+ method,
41
+ path,
42
+ agent: false,
43
+ maxHeaderSize: 16384,
44
+ headers: {
45
+ connection: "close",
46
+ ...body === void 0 ? {} : { "content-length": body.byteLength },
47
+ ...contentType === void 0 ? {} : { "content-type": contentType }
48
+ }
49
+ });
50
+ const settleIfClosed = () => {
51
+ if (settled || outcome === void 0 || !requestClosed || response !== void 0 && !responseClosed || upgradedSocket !== void 0 && !upgradedSocketClosed) return;
52
+ settled = true;
53
+ clearTimeout(timer);
54
+ signal?.removeEventListener("abort", onAbort);
55
+ if (outcome.kind === "failure") reject(outcome.error);
56
+ else resolve({ statusCode: outcome.statusCode, body: Buffer.concat(chunks, receivedBytes) });
57
+ chunks.length = 0;
58
+ };
59
+ const fail = (error) => {
60
+ if (settled || outcome?.kind === "failure") return;
61
+ outcome = { kind: "failure", error };
62
+ chunks.length = 0;
63
+ response?.destroy();
64
+ upgradedSocket?.destroy();
65
+ request.destroy();
66
+ settleIfClosed();
67
+ };
68
+ const deadlineExpired = () => {
69
+ if (performance.now() < deadline) return false;
70
+ fail(new DockerEngineTransportError("deadline", "Engine request deadline exceeded"));
71
+ return true;
72
+ };
73
+ const onAbort = () => fail(new DockerEngineTransportError("aborted", "Engine request aborted"));
74
+ const timer = setTimeout(() => {
75
+ fail(new DockerEngineTransportError("deadline", "Engine request deadline exceeded"));
76
+ }, Math.max(1, Math.ceil(deadline - performance.now())));
77
+ request.on("error", (cause) => {
78
+ const malformedResponse = "code" in cause && typeof cause.code === "string" && cause.code.startsWith("HPE_");
79
+ fail(new DockerEngineTransportError(
80
+ malformedResponse ? "invalid_response" : "connection",
81
+ malformedResponse ? "Engine response has invalid HTTP framing" : "Engine request connection failed",
82
+ { cause }
83
+ ));
84
+ });
85
+ request.once("close", () => {
86
+ requestClosed = true;
87
+ if (outcome === void 0 && response === void 0 && upgradedSocket === void 0) {
88
+ fail(new DockerEngineTransportError("connection", "Engine connection closed before a response"));
89
+ }
90
+ settleIfClosed();
91
+ });
92
+ request.once("upgrade", (_response, socket) => {
93
+ upgradedSocket = socket;
94
+ socket.on("error", () => {
95
+ });
96
+ socket.once("close", () => {
97
+ upgradedSocketClosed = true;
98
+ settleIfClosed();
99
+ });
100
+ fail(new DockerEngineTransportError("invalid_response", "Engine response unexpectedly upgraded the connection"));
101
+ });
102
+ request.once("response", (message) => {
103
+ response = message;
104
+ message.on("error", (cause) => {
105
+ fail(new DockerEngineTransportError("invalid_response", "Engine response stream failed", { cause }));
106
+ });
107
+ message.once("aborted", () => {
108
+ fail(new DockerEngineTransportError("invalid_response", "Engine response was truncated"));
109
+ });
110
+ message.once("close", () => {
111
+ responseClosed = true;
112
+ if (outcome === void 0) fail(new DockerEngineTransportError("invalid_response", "Engine response closed before completion"));
113
+ settleIfClosed();
114
+ });
115
+ message.on("data", (chunk) => {
116
+ if (outcome !== void 0 || deadlineExpired()) return;
117
+ if (chunk.byteLength > maxResponseBytes - receivedBytes) {
118
+ fail(new DockerEngineTransportError("response_limit", "Engine response exceeds its byte limit"));
119
+ return;
120
+ }
121
+ receivedBytes += chunk.byteLength;
122
+ chunks.push(chunk);
123
+ });
124
+ message.once("end", () => {
125
+ if (outcome !== void 0 || deadlineExpired()) return;
126
+ if (!message.complete || expectedBytes !== void 0 && receivedBytes !== expectedBytes) {
127
+ fail(new DockerEngineTransportError("invalid_response", "Engine response length is inconsistent"));
128
+ return;
129
+ }
130
+ if (message.statusCode === void 0) {
131
+ fail(new DockerEngineTransportError("invalid_response", "Engine response has no status code"));
132
+ return;
133
+ }
134
+ outcome = { kind: "success", statusCode: message.statusCode };
135
+ settleIfClosed();
136
+ });
137
+ if (outcome !== void 0) {
138
+ message.destroy();
139
+ return;
140
+ }
141
+ if (deadlineExpired()) return;
142
+ const contentLength = message.headers["content-length"];
143
+ if (contentLength !== void 0) {
144
+ const length = Number(contentLength);
145
+ if (!/^\d+$/u.test(contentLength) || !Number.isSafeInteger(length)) {
146
+ fail(new DockerEngineTransportError("invalid_response", "Engine response has an invalid content length"));
147
+ return;
148
+ }
149
+ if (message.statusCode !== 204 && message.statusCode !== 304) expectedBytes = length;
150
+ if (length > maxResponseBytes) {
151
+ fail(new DockerEngineTransportError("response_limit", "Engine response exceeds its byte limit"));
152
+ }
153
+ }
154
+ });
155
+ signal?.addEventListener("abort", onAbort, { once: true });
156
+ if (signal?.aborted) onAbort();
157
+ if (outcome === void 0) request.end(body);
158
+ });
159
+ }
160
+ }
161
+ export {
162
+ DockerEngine,
163
+ DockerEngineTransportError2 as DockerEngineTransportError
164
+ };
@@ -0,0 +1,40 @@
1
+ import type { IncomingHttpHeaders } from 'node:http';
2
+ import type { SandboxByteSource } from '@agen-ai/sandbox';
3
+ export interface DockerEngineRequestOptions {
4
+ readonly timeoutMs: number;
5
+ readonly maxResponseBytes: number;
6
+ readonly body?: Uint8Array;
7
+ readonly contentType?: 'application/json' | 'application/x-tar';
8
+ readonly signal?: AbortSignal;
9
+ }
10
+ export interface DockerEngineStreamOptions {
11
+ readonly body?: SandboxByteSource | ((signal: AbortSignal) => SandboxByteSource);
12
+ readonly contentType?: 'application/json' | 'application/x-tar';
13
+ readonly signal?: AbortSignal;
14
+ readonly chunkBytes: number;
15
+ readonly connectTimeoutMs?: number;
16
+ readonly headerTimeoutMs?: number;
17
+ }
18
+ export interface DockerEngineStreamResponse {
19
+ readonly statusCode: number;
20
+ readonly headers: IncomingHttpHeaders;
21
+ readonly rawHeaders: readonly string[];
22
+ readonly body: AsyncIterable<Uint8Array>;
23
+ readonly signal: AbortSignal;
24
+ }
25
+ export interface DockerEngineStreamResult<Value> {
26
+ readonly value: Value;
27
+ readonly uploadedBytes: bigint;
28
+ readonly downloadedBytes: bigint;
29
+ }
30
+ export interface DockerEngineResponse {
31
+ readonly statusCode: number;
32
+ readonly body: Uint8Array;
33
+ }
34
+ export type DockerEngineTransportErrorCode = 'deadline' | 'aborted' | 'response_limit' | 'invalid_response' | 'connection';
35
+ export declare class DockerEngineTransportError extends Error {
36
+ readonly code: DockerEngineTransportErrorCode;
37
+ constructor(code: DockerEngineTransportErrorCode, message: string, options?: ErrorOptions);
38
+ }
39
+ export declare function validateEngineRequest(path: string, { timeoutMs, maxResponseBytes }: DockerEngineRequestOptions): void;
40
+ //# sourceMappingURL=contracts.d.ts.map
@@ -0,0 +1,22 @@
1
+ class DockerEngineTransportError extends Error {
2
+ constructor(code, message, options) {
3
+ super(message, options);
4
+ this.code = code;
5
+ this.name = "DockerEngineTransportError";
6
+ }
7
+ }
8
+ function validateEngineRequest(path, { timeoutMs, maxResponseBytes }) {
9
+ if (!path.startsWith("/") || path.startsWith("//") || !/^[\x21-\x7e]+$/u.test(path) || /[\\#]/u.test(path) || path.length > 16384) {
10
+ throw new TypeError("Engine request path must be a bounded HTTP origin-form target");
11
+ }
12
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) {
13
+ throw new TypeError("Engine request timeout must be a finite timer duration");
14
+ }
15
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 0) {
16
+ throw new TypeError("Engine response limit must be a nonnegative safe integer");
17
+ }
18
+ }
19
+ export {
20
+ DockerEngineTransportError,
21
+ validateEngineRequest
22
+ };
@@ -0,0 +1,18 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ export declare class DockerResponseBody {
3
+ private readonly response;
4
+ private readonly chunkBytes;
5
+ private readonly signal;
6
+ private readonly interrupt;
7
+ private active;
8
+ private entered;
9
+ private drained;
10
+ private pending;
11
+ private failure;
12
+ bytes: bigint;
13
+ constructor(response: IncomingMessage, chunkBytes: number, signal: AbortSignal, interrupt: (error: unknown) => void);
14
+ readonly body: AsyncIterable<Uint8Array>;
15
+ private next;
16
+ retire(): Promise<void>;
17
+ }
18
+ //# sourceMappingURL=response.d.ts.map