@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
@@ -0,0 +1,313 @@
1
+ import { z } from "zod/v4";
2
+ import { SandboxOperationError, SandboxStateError, SandboxUnsupportedError, parseSandboxGuestPath, parseSandboxRecoveryRecord } from "@agen-ai/sandbox";
3
+ import { createSandboxSpillStore, verifySandboxSpillDirectory } from "@agen-ai/sandbox/node";
4
+ import { DOCKER_API } from "../policy/config.js";
5
+ import { assertDockerController, assertDockerPolicy, verifyDaemon, verifyDatasetImages, verifyImage } from "../policy/preflight.js";
6
+ import { dockerProfile } from "../policy/profiles.js";
7
+ import { containerBody, verifyContainer } from "../policy/container.js";
8
+ import { newRecoveryRecord, resourceLabels } from "../recovery/identity.js";
9
+ import { importWorkspace } from "../workspace/imports.js";
10
+ import { exportWorkspace } from "../workspace/exports.js";
11
+ import { DockerProcessOwner } from "../process/owner.js";
12
+ import { MAX_DATASET_ATTACHMENTS } from "../datasets/configuration.js";
13
+ import { hasUnexpectedImageMountWarnings } from "../datasets/warnings.js";
14
+ import { inspectResource } from "./inspect.js";
15
+ const containerCreatedSchema = z.object({ Id: z.string().regex(/^[a-f0-9]{64}$/u), Warnings: z.array(z.string()).max(MAX_DATASET_ATTACHMENTS).nullable().optional() });
16
+ const volumeCreatedSchema = z.object({ Name: z.string() });
17
+ const networkCreatedSchema = z.object({ Id: z.string().regex(/^[a-f0-9]{64}$/u), Warning: z.string().optional() });
18
+ class DockerResourceOwner {
19
+ constructor(api, config, request, record) {
20
+ this.api = api;
21
+ this.config = config;
22
+ this.request = request;
23
+ if (record) this.current = record;
24
+ else if (request) this.current = newRecoveryRecord(config, request);
25
+ else throw new SandboxStateError("Docker resource ownership needs a request or verified recovery record.");
26
+ }
27
+ current;
28
+ closed = false;
29
+ startActual;
30
+ stopActual;
31
+ destroyActual;
32
+ checkpoint;
33
+ checkpointTail = Promise.resolve();
34
+ returnedIds = /* @__PURE__ */ new Map();
35
+ createEffects = /* @__PURE__ */ new Map();
36
+ absent = /* @__PURE__ */ new Set();
37
+ process;
38
+ get id() {
39
+ return this.current.sandboxId;
40
+ }
41
+ get recoveryRecord() {
42
+ return this.current;
43
+ }
44
+ start(options) {
45
+ if (this.startActual || !this.request) return Promise.reject(new SandboxStateError("Docker resource startup is once-only."));
46
+ this.checkpoint = options.checkpoint;
47
+ this.startActual = this.acquire(this.request.policy, options);
48
+ return this.startActual;
49
+ }
50
+ async acquire(policy, options) {
51
+ assertDockerPolicy(policy);
52
+ this.assertOpen(options.signal);
53
+ assertDockerController(this.config);
54
+ await verifySandboxSpillDirectory(this.config.spillDirectory, { signal: options.signal });
55
+ this.assertOpen(options.signal);
56
+ const backendIdentity = await verifyDaemon(this.api, this.config, options.signal);
57
+ await verifyImage(this.api, this.config, options.signal);
58
+ await verifyDatasetImages(this.api, this.config, options.signal);
59
+ this.assertOpen(options.signal);
60
+ await this.publish({ ...this.current, phase: "starting", authority: { kind: "verified", backendIdentity } });
61
+ const storage = await this.createResource("storage", options.signal, async (resource) => {
62
+ const result = await this.api.json(
63
+ "POST",
64
+ `${DOCKER_API}/volumes/create`,
65
+ volumeCreatedSchema,
66
+ { body: { Name: resource.name, Driver: "local", Labels: resourceLabels(this.current, "storage") } }
67
+ );
68
+ return { id: result.Name };
69
+ });
70
+ const network = policy.network.kind === "outbound" ? await this.createResource("network", options.signal, async (resource) => {
71
+ const result = await this.api.json("POST", `${DOCKER_API}/networks/create`, networkCreatedSchema, { body: {
72
+ Name: resource.name,
73
+ Driver: "bridge",
74
+ Internal: false,
75
+ Attachable: false,
76
+ Ingress: false,
77
+ EnableIPv6: false,
78
+ CheckDuplicate: true,
79
+ Labels: resourceLabels(this.current, "network")
80
+ } });
81
+ return { id: result.Id, warning: Boolean(result.Warning) };
82
+ }) : void 0;
83
+ const platform = `linux/${dockerProfile(this.config.profile).imageArchitecture}`;
84
+ const createContainer = (role) => this.createResource(role, options.signal, async (resource) => {
85
+ const result = await this.api.json(
86
+ "POST",
87
+ `${DOCKER_API}/containers/create?name=${encodeURIComponent(resource.name)}&platform=${encodeURIComponent(platform)}`,
88
+ containerCreatedSchema,
89
+ { body: containerBody(this.config, this.current, policy, role, storage.id, network?.id) }
90
+ );
91
+ return {
92
+ id: result.Id,
93
+ warning: hasUnexpectedImageMountWarnings(result.Warnings ?? [], role === "compute" ? this.config.datasets.length : 0)
94
+ };
95
+ }, (inspection) => {
96
+ if (!inspection.container) throw new SandboxStateError("Docker did not inspect the created container.");
97
+ verifyContainer(inspection.container, this.config, policy, role, storage.id, network?.id, false);
98
+ });
99
+ const compute = await createContainer("compute");
100
+ await createContainer("export");
101
+ this.assertOpen(options.signal);
102
+ await this.assertAuthority(options.signal);
103
+ await importWorkspace(this.api, compute.id, (async function* () {
104
+ })(), this.config.user, {
105
+ signal: options.signal ?? new AbortController().signal,
106
+ buffers: policy.transfers,
107
+ quotas: {}
108
+ });
109
+ this.assertOpen(options.signal);
110
+ await this.assertAuthority(options.signal);
111
+ await this.api.request("POST", `${DOCKER_API}/containers/${compute.id}/start`, { signal: options.signal });
112
+ this.assertOpen(options.signal);
113
+ const running = await inspectResource(this.api, this.current, this.resource("compute"), compute.id, options.signal);
114
+ if (!running?.container) throw new SandboxStateError("Docker workload disappeared during startup.");
115
+ verifyContainer(running.container, this.config, policy, "compute", storage.id, network?.id, true);
116
+ await this.publish({ ...this.current, phase: "running" });
117
+ this.assertOpen(options.signal);
118
+ return Object.freeze({
119
+ effective: Object.freeze({
120
+ backendIdentity,
121
+ isolation: "container",
122
+ resources: policy.resources,
123
+ temporaryStorage: policy.temporaryStorage,
124
+ network: policy.network,
125
+ nonRoot: true,
126
+ readOnlyRoot: true,
127
+ workspaceDiskQuota: Object.freeze({ kind: "not_requested" })
128
+ }),
129
+ workspace: parseSandboxGuestPath("/workspace/work"),
130
+ openSpill: (operation) => createSandboxSpillStore({ directory: this.config.spillDirectory, signal: operation?.signal }),
131
+ importTree: (entries, operation) => this.importTree(entries, operation),
132
+ reserveProcess: (spec) => {
133
+ this.assertOpen();
134
+ if (this.process) throw new SandboxStateError("Docker admits only one process reservation.");
135
+ this.process = new DockerProcessOwner(this.api, this.config, compute.id, spec, policy, async (signal) => {
136
+ this.assertOpen(signal);
137
+ await this.assertAuthority(signal);
138
+ await this.requirePresent("compute", signal);
139
+ this.assertOpen(signal);
140
+ }, () => this.stop());
141
+ return this.process;
142
+ },
143
+ exportTree: (selection, consume, operation) => this.exportTree(selection, consume, operation, policy, storage.id, network?.id)
144
+ });
145
+ }
146
+ async importTree(entries, options) {
147
+ this.assertOpen(options.signal);
148
+ await this.assertAuthority(options.signal);
149
+ const compute = await this.requirePresent("compute", options.signal);
150
+ this.assertOpen(options.signal);
151
+ return importWorkspace(this.api, compute.id, entries, this.config.user, options);
152
+ }
153
+ async exportTree(selection, consume, options, policy, storageId, networkId) {
154
+ await this.assertAuthority(options.signal);
155
+ if (await this.resolveResource("compute", options.signal)) throw new SandboxStateError("Workspace export requires exact compute absence.");
156
+ await this.requirePresent("storage", options.signal);
157
+ const accessor = await this.requirePresent("export", options.signal);
158
+ if (!accessor.container) throw new SandboxStateError("Workspace export requires the exact archive accessor.");
159
+ verifyContainer(accessor.container, this.config, policy, "export", storageId, networkId, false);
160
+ return exportWorkspace(this.api, accessor.id, selection, consume, options);
161
+ }
162
+ async createResource(role, signal, create, validate) {
163
+ this.assertOpen(signal);
164
+ await this.assertAuthority(signal);
165
+ await this.updateIdentity(role, { kind: "creating" });
166
+ this.assertOpen(signal);
167
+ const effect = create(this.resource(role)).then((created) => {
168
+ this.returnedIds.set(role, created.id);
169
+ return created;
170
+ });
171
+ this.createEffects.set(role, effect);
172
+ const { id, warning } = await effect;
173
+ if (warning) throw new SandboxUnsupportedError("Docker warned while creating the requested resource.");
174
+ const resource = this.resource(role);
175
+ const inspection = await inspectResource(this.api, this.current, resource, id);
176
+ if (!inspection || inspection.id !== id) throw new SandboxStateError("Docker did not confirm the exact created resource ID.");
177
+ validate?.(inspection);
178
+ await this.updateIdentity(role, { kind: "identified", id, fingerprint: inspection.fingerprint });
179
+ this.assertOpen(signal);
180
+ return inspection;
181
+ }
182
+ stop() {
183
+ this.closed = true;
184
+ this.process?.closeAdmission();
185
+ this.stopActual ??= this.cleanupCompute().catch((error) => {
186
+ this.stopActual = void 0;
187
+ throw error;
188
+ });
189
+ return this.stopActual;
190
+ }
191
+ async cleanupCompute() {
192
+ this.cleanupPhase("stopping");
193
+ const entered = this.createEffects.get("compute");
194
+ if (entered || !this.request) {
195
+ try {
196
+ await this.deleteResource("compute");
197
+ } catch (error) {
198
+ if (!entered) throw error;
199
+ }
200
+ await entered?.catch(() => {
201
+ });
202
+ if (!this.absent.has("compute")) await this.deleteResource("compute");
203
+ }
204
+ await this.process?.computeStopped();
205
+ this.cleanupPhase("stopped");
206
+ await this.checkpointTail;
207
+ }
208
+ destroy() {
209
+ this.closed = true;
210
+ this.destroyActual ??= this.cleanupAll().catch((error) => {
211
+ this.destroyActual = void 0;
212
+ throw error;
213
+ });
214
+ return this.destroyActual;
215
+ }
216
+ async cleanupAll() {
217
+ const failures = [];
218
+ try {
219
+ await this.stop();
220
+ } catch (error) {
221
+ failures.push(error);
222
+ }
223
+ this.cleanupPhase("destroying");
224
+ for (const role of ["export", "storage", "network"]) {
225
+ if (!this.current.resources.some((resource) => resource.role === role)) continue;
226
+ try {
227
+ await this.deleteResource(role);
228
+ } catch (error) {
229
+ failures.push(error);
230
+ }
231
+ }
232
+ if (failures.length) {
233
+ await this.checkpointTail;
234
+ throw new AggregateError(failures, "Docker cleanup retains unresolved resource ownership.");
235
+ }
236
+ this.cleanupPhase("destroyed");
237
+ await this.checkpointTail;
238
+ }
239
+ async resolveResource(role, signal) {
240
+ const resource = this.resource(role);
241
+ if (resource.identity.kind === "planned") return void 0;
242
+ if (this.request !== void 0 && !this.createEffects.has(role)) return void 0;
243
+ const reference = resource.identity.kind === "identified" ? resource.identity.id : this.returnedIds.get(role) ?? resource.name;
244
+ const inspection = await inspectResource(this.api, this.current, resource, reference, signal);
245
+ if (!inspection && resource.identity.kind === "creating") {
246
+ throw new SandboxStateError("Docker acquisition entered without a settled resource identity; an absent name cannot prove absence.");
247
+ }
248
+ if (inspection && resource.identity.kind === "creating") {
249
+ this.publishCleanup({ ...this.current, resources: this.current.resources.map((candidate) => candidate.role === role ? {
250
+ ...candidate,
251
+ identity: { kind: "identified", id: inspection.id, fingerprint: inspection.fingerprint }
252
+ } : candidate) });
253
+ }
254
+ return inspection;
255
+ }
256
+ async requirePresent(role, signal) {
257
+ const inspection = await this.resolveResource(role, signal);
258
+ if (!inspection) throw new SandboxStateError("The exact Docker resource is absent.");
259
+ return inspection;
260
+ }
261
+ async deleteResource(role) {
262
+ if (this.absent.has(role) || this.resource(role).identity.kind === "planned") return;
263
+ await this.assertAuthority();
264
+ const inspection = await this.resolveResource(role);
265
+ if (!inspection) {
266
+ this.absent.add(role);
267
+ return;
268
+ }
269
+ const id = encodeURIComponent(inspection.id);
270
+ const path = role === "compute" || role === "export" ? `${DOCKER_API}/containers/${id}?force=true&v=false` : role === "storage" ? `${DOCKER_API}/volumes/${id}` : `${DOCKER_API}/networks/${id}`;
271
+ await this.api.request("DELETE", path, { statuses: [204, 404] });
272
+ const remaining = await this.resolveResource(role);
273
+ if (remaining) throw new SandboxStateError("Docker acknowledged removal but the exact resource remains present.");
274
+ this.absent.add(role);
275
+ }
276
+ resource(role) {
277
+ const resource = this.current.resources.find((candidate) => candidate.role === role);
278
+ if (!resource) throw new SandboxStateError("Docker recovery record omits an owned resource role.");
279
+ return resource;
280
+ }
281
+ updateIdentity(role, identity) {
282
+ const next = { ...this.current, resources: this.current.resources.map((resource) => resource.role === role ? { ...resource, identity } : resource) };
283
+ return this.publish(next);
284
+ }
285
+ cleanupPhase(phase) {
286
+ if (this.current.authority.kind === "verified") this.publishCleanup({ ...this.current, phase });
287
+ }
288
+ publishCleanup(record) {
289
+ this.current = parseSandboxRecoveryRecord(record);
290
+ this.submitCheckpoint(this.current);
291
+ }
292
+ async publish(record) {
293
+ this.current = parseSandboxRecoveryRecord(record);
294
+ await this.submitCheckpoint(this.current);
295
+ }
296
+ submitCheckpoint(record) {
297
+ const writing = this.checkpoint?.(record) ?? Promise.resolve();
298
+ this.checkpointTail = Promise.allSettled([this.checkpointTail, writing]).then(() => {
299
+ });
300
+ return writing;
301
+ }
302
+ async assertAuthority(signal) {
303
+ if (this.current.authority.kind !== "verified" || await verifyDaemon(this.api, this.config, signal) !== this.current.authority.backendIdentity) {
304
+ throw new SandboxStateError("Docker resource operations cannot act on a different or unverified daemon.");
305
+ }
306
+ }
307
+ assertOpen(signal) {
308
+ if (this.closed || signal?.aborted) throw new SandboxOperationError("cancelled", "Docker sandbox admission is closed.");
309
+ }
310
+ }
311
+ export {
312
+ DockerResourceOwner
313
+ };
@@ -0,0 +1,5 @@
1
+ import type { SandboxExportConsumer, SandboxInputTreeSelection, SandboxTransferSummary } from '@agen-ai/sandbox';
2
+ import type { SandboxDriverTransferOptions } from '@agen-ai/sandbox/backend';
3
+ import type { DockerApi } from '../resources/api.js';
4
+ export declare function exportWorkspace(api: DockerApi, containerId: string, selection: SandboxInputTreeSelection, consume: SandboxExportConsumer, options: SandboxDriverTransferOptions): Promise<SandboxTransferSummary>;
5
+ //# sourceMappingURL=exports.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { DOCKER_API } from "../policy/config.js";
2
+ import { ARCHIVE_ROOT, ARCHIVE_WORK, unsafeArchive, workspaceName } from "./paths.js";
3
+ import { pathStat, requireStat } from "./stat.js";
4
+ import { exportSelectedPath } from "./selected.js";
5
+ import { readTar } from "./tar/decode.js";
6
+ async function workspace(api, containerId, consume, options) {
7
+ let root = false;
8
+ let work = false;
9
+ const result = await api.stream(
10
+ "GET",
11
+ `${DOCKER_API}/containers/${containerId}/archive?path=%2Fworkspace`,
12
+ { signal: options.signal, chunkBytes: options.buffers.chunkBytes },
13
+ async (response) => {
14
+ requireStat(pathStat(response), "/workspace", "directory");
15
+ const summary = await readTar(response.body, async (entry) => {
16
+ const path = workspaceName(entry.path, entry.kind === "directory");
17
+ if (path === ARCHIVE_ROOT) {
18
+ if (root) unsafeArchive("duplicate workspace root.");
19
+ root = true;
20
+ } else if (path === ARCHIVE_WORK) {
21
+ if (work) unsafeArchive("duplicate work root.");
22
+ work = true;
23
+ } else {
24
+ const relative = path.slice(ARCHIVE_WORK.length + 1);
25
+ await consume(entry.kind === "directory" ? { kind: "directory", path: relative } : { kind: "file", path: relative, byteLength: entry.size, executable: (entry.mode & 73) !== 0, bytes: entry.body }, { signal: options.signal });
26
+ }
27
+ }, { signal: options.signal, chunkBytes: options.buffers.chunkBytes, limitCode: "output_limit", maxArchiveBytes: options.quotas.maxArchiveBytes });
28
+ if (!root || !work) unsafeArchive("both fixed workspace directory records are required.");
29
+ return { ...summary, directories: summary.directories - 2n };
30
+ }
31
+ );
32
+ return result.value;
33
+ }
34
+ async function exportWorkspace(api, containerId, selection, consume, options) {
35
+ if (selection.kind === "workspace") return workspace(api, containerId, consume, options);
36
+ if (selection.kind === "subtree") return exportSelectedPath(api, containerId, selection.path, "directory", consume, options, options.quotas.maxArchiveBytes);
37
+ let files = 0n;
38
+ let payloadBytes = 0n;
39
+ let archiveBytes = 0n;
40
+ for await (const path of selection.paths) {
41
+ options.signal.throwIfAborted();
42
+ const remaining = options.quotas.maxArchiveBytes === void 0 ? void 0 : options.quotas.maxArchiveBytes - archiveBytes;
43
+ const result = await exportSelectedPath(api, containerId, path, "file", consume, options, remaining);
44
+ files += result.files;
45
+ payloadBytes += result.payloadBytes;
46
+ archiveBytes += result.archiveBytes;
47
+ }
48
+ return Object.freeze({ files, directories: 0n, payloadBytes, archiveBytes });
49
+ }
50
+ export {
51
+ exportWorkspace
52
+ };
@@ -0,0 +1,8 @@
1
+ import type { SandboxInputTreeEntry, SandboxTransferSummary } from '@agen-ai/sandbox';
2
+ import type { SandboxDriverTransferOptions } from '@agen-ai/sandbox/backend';
3
+ import type { DockerApi } from '../resources/api.js';
4
+ export declare function importWorkspace(api: DockerApi, containerId: string, entries: AsyncIterable<SandboxInputTreeEntry>, user: Readonly<{
5
+ uid: number;
6
+ gid: number;
7
+ }>, options: SandboxDriverTransferOptions): Promise<SandboxTransferSummary>;
8
+ //# sourceMappingURL=imports.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { SandboxOperationError } from "@agen-ai/sandbox";
2
+ import { DOCKER_API } from "../policy/config.js";
3
+ import { writeTar } from "./tar/encode.js";
4
+ async function importWorkspace(api, containerId, entries, user, options) {
5
+ let files = 0n;
6
+ let directories = 0n;
7
+ let payloadBytes = 0n;
8
+ const source = async function* (signal) {
9
+ const directory = (path) => ({ ...user, path, kind: "directory", size: 0, mode: 448 });
10
+ yield directory("work");
11
+ for await (const entry of entries) {
12
+ signal.throwIfAborted();
13
+ const parts = entry.path.split("/");
14
+ const parentCount = entry.kind === "directory" ? parts.length : parts.length - 1;
15
+ for (let count = 1; count <= parentCount; count += 1) yield directory(`work/${parts.slice(0, count).join("/")}`);
16
+ if (entry.kind === "directory") {
17
+ directories += 1n;
18
+ continue;
19
+ }
20
+ files += 1n;
21
+ payloadBytes += BigInt(entry.byteLength);
22
+ const body = async function* () {
23
+ yield* entry.open({ signal });
24
+ };
25
+ yield { ...user, path: `work/${entry.path}`, kind: "file", size: entry.byteLength, mode: entry.executable ? 448 : 384, body: body() };
26
+ }
27
+ };
28
+ const result = await api.stream("PUT", `${DOCKER_API}/containers/${containerId}/archive?path=%2Fworkspace&noOverwriteDirNonDir=true`, {
29
+ signal: options.signal,
30
+ chunkBytes: options.buffers.chunkBytes,
31
+ contentType: "application/x-tar",
32
+ body: (signal) => writeTar(source(signal), {
33
+ signal,
34
+ chunkBytes: options.buffers.chunkBytes,
35
+ limitCode: "input_limit",
36
+ maxArchiveBytes: options.quotas.maxArchiveBytes
37
+ })
38
+ }, async ({ body }) => {
39
+ let bytes = 0;
40
+ for await (const chunk of body) {
41
+ bytes += chunk.length;
42
+ if (bytes > 64 * 1024) throw new SandboxOperationError("driver_failure", "Docker archive acknowledgement exceeds its control-response bound.");
43
+ }
44
+ });
45
+ return Object.freeze({ files, directories, payloadBytes, archiveBytes: result.uploadedBytes });
46
+ }
47
+ export {
48
+ importWorkspace
49
+ };
@@ -0,0 +1,8 @@
1
+ import { SandboxOperationError } from '@agen-ai/sandbox';
2
+ export declare const ARCHIVE_ROOT = "workspace";
3
+ export declare const ARCHIVE_WORK = "workspace/work";
4
+ export declare function archiveError(message: string): SandboxOperationError;
5
+ export declare function unsafeArchive(message: string): never;
6
+ export declare function archiveName(name: string, directory: boolean): string;
7
+ export declare function workspaceName(name: string, directory: boolean): string;
8
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { SANDBOX_LIMITS, SandboxOperationError, parseSandboxRelativePath } from "@agen-ai/sandbox";
2
+ const ARCHIVE_ROOT = "workspace";
3
+ const ARCHIVE_WORK = `${ARCHIVE_ROOT}/work`;
4
+ function archiveError(message) {
5
+ return new SandboxOperationError("driver_failure", `Unsafe workspace archive: ${message}`);
6
+ }
7
+ function unsafeArchive(message) {
8
+ throw archiveError(message);
9
+ }
10
+ function archiveName(name, directory) {
11
+ const canonical = directory && name.endsWith("/") ? name.slice(0, -1) : name;
12
+ const segments = canonical.split("/");
13
+ if (Buffer.byteLength(canonical) > SANDBOX_LIMITS.maxPathBytes + ARCHIVE_WORK.length + 1 || segments.length > SANDBOX_LIMITS.maxPathSegments + 2 || /[\\\p{Cc}]/u.test(canonical) || segments.some((part) => !part || part === "." || part === ".." || Buffer.byteLength(part) > SANDBOX_LIMITS.maxPathSegmentBytes)) {
14
+ unsafeArchive("entry names must be bounded canonical relative paths.");
15
+ }
16
+ return canonical;
17
+ }
18
+ function workspaceName(name, directory) {
19
+ const canonical = archiveName(name, directory);
20
+ if (canonical === ARCHIVE_ROOT || canonical === ARCHIVE_WORK) {
21
+ if (!directory) unsafeArchive("workspace roots must be directories.");
22
+ return canonical;
23
+ }
24
+ if (!canonical.startsWith(`${ARCHIVE_WORK}/`)) unsafeArchive("entries must remain inside the owned work directory.");
25
+ try {
26
+ parseSandboxRelativePath(canonical.slice(ARCHIVE_WORK.length + 1));
27
+ } catch {
28
+ unsafeArchive("work entries exceed the public relative-path bounds.");
29
+ }
30
+ return canonical;
31
+ }
32
+ export {
33
+ ARCHIVE_ROOT,
34
+ ARCHIVE_WORK,
35
+ archiveError,
36
+ archiveName,
37
+ unsafeArchive,
38
+ workspaceName
39
+ };
@@ -0,0 +1,5 @@
1
+ import type { SandboxExportConsumer, SandboxTransferSummary } from '@agen-ai/sandbox';
2
+ import type { SandboxDriverTransferOptions } from '@agen-ai/sandbox/backend';
3
+ import type { DockerApi } from '../resources/api.js';
4
+ export declare function exportSelectedPath(api: DockerApi, containerId: string, path: string, kind: 'file' | 'directory', consume: SandboxExportConsumer, options: SandboxDriverTransferOptions, maxArchiveBytes: bigint | undefined): Promise<SandboxTransferSummary>;
5
+ //# sourceMappingURL=selected.d.ts.map
@@ -0,0 +1,34 @@
1
+ import { DOCKER_API } from "../policy/config.js";
2
+ import { unsafeArchive } from "./paths.js";
3
+ import { pathStat, sameStat, selectedPathStat } from "./stat.js";
4
+ import { readTar } from "./tar/decode.js";
5
+ async function exportSelectedPath(api, containerId, path, kind, consume, options, maxArchiveBytes) {
6
+ const stat = await selectedPathStat(api, containerId, path, kind, options.signal, options.buffers.chunkBytes);
7
+ const result = await api.stream(
8
+ "GET",
9
+ `${DOCKER_API}/containers/${containerId}/archive?path=${encodeURIComponent(`/workspace/work/${path}`)}`,
10
+ { signal: options.signal, chunkBytes: options.buffers.chunkBytes },
11
+ async (response) => {
12
+ sameStat(pathStat(response), stat);
13
+ let observedRoot = false;
14
+ const summary = await readTar(response.body, async (entry) => {
15
+ let relative;
16
+ if (entry.path === stat.name) {
17
+ if (observedRoot || entry.kind !== kind || (entry.mode & 511) !== (stat.mode & 511) || kind === "file" && entry.size !== stat.size) unsafeArchive("selected archive requires one matching root entry.");
18
+ observedRoot = true;
19
+ relative = path;
20
+ } else {
21
+ if (kind !== "directory" || !entry.path.startsWith(`${stat.name}/`)) unsafeArchive("archive entry is outside the selected root.");
22
+ relative = `${path}${entry.path.slice(stat.name.length)}`;
23
+ }
24
+ await consume(entry.kind === "directory" ? { kind: "directory", path: relative } : { kind: "file", path: relative, byteLength: entry.size, executable: (entry.mode & 73) !== 0, bytes: entry.body }, { signal: options.signal });
25
+ }, { signal: options.signal, chunkBytes: options.buffers.chunkBytes, limitCode: "output_limit", maxArchiveBytes });
26
+ if (!observedRoot) unsafeArchive("selected root is missing from its archive.");
27
+ return summary;
28
+ }
29
+ );
30
+ return result.value;
31
+ }
32
+ export {
33
+ exportSelectedPath
34
+ };
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod/v4';
2
+ import type { DockerEngineStreamResponse } from '../engine/contracts.js';
3
+ import type { DockerApi } from '../resources/api.js';
4
+ declare const statSchema: z.ZodObject<{
5
+ name: z.ZodString;
6
+ size: z.ZodNumber;
7
+ mode: z.ZodNumber;
8
+ mtime: z.ZodString;
9
+ linkTarget: z.ZodString;
10
+ }, z.core.$strict>;
11
+ export type DockerPathStat = z.infer<typeof statSchema>;
12
+ export declare function pathStat(response: DockerEngineStreamResponse): DockerPathStat;
13
+ export declare function requireStat(stat: DockerPathStat, path: string, kind: 'file' | 'directory'): void;
14
+ export declare function sameStat(actual: DockerPathStat, expected: DockerPathStat): void;
15
+ export declare function statPath(api: DockerApi, containerId: string, path: string, signal: AbortSignal, chunkBytes: number): Promise<DockerPathStat>;
16
+ export declare function selectedPathStat(api: DockerApi, containerId: string, relative: string, kind: 'file' | 'directory', signal: AbortSignal, chunkBytes: number): Promise<DockerPathStat>;
17
+ export {};
18
+ //# sourceMappingURL=stat.d.ts.map
@@ -0,0 +1,63 @@
1
+ import { z } from "zod/v4";
2
+ import { DOCKER_API } from "../policy/config.js";
3
+ import { unsafeArchive } from "./paths.js";
4
+ const statSchema = z.strictObject({
5
+ name: z.string().min(1).max(1024),
6
+ size: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
7
+ mode: z.number().int().nonnegative().max(4294967295),
8
+ mtime: z.string().min(1).max(64),
9
+ linkTarget: z.string().max(4096)
10
+ });
11
+ const MODE_DIRECTORY = 2147483648;
12
+ const MODE_TYPE = 2401763328;
13
+ function pathStat(response) {
14
+ const name = "x-docker-container-path-stat";
15
+ const encoded = response.headers[name];
16
+ let count = 0;
17
+ for (let index = 0; index < response.rawHeaders.length; index += 2) if (response.rawHeaders[index].toLowerCase() === name) count += 1;
18
+ if (count !== 1 || typeof encoded !== "string" || encoded.length > 8192 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded)) unsafeArchive("Docker stat header must be one bounded canonical Base64 value.");
19
+ const bytes = Buffer.from(encoded, "base64");
20
+ if (bytes.toString("base64") !== encoded) unsafeArchive("Docker stat header is not canonical Base64.");
21
+ let value;
22
+ try {
23
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
24
+ } catch {
25
+ return unsafeArchive("Docker stat header requires valid UTF-8 JSON.");
26
+ }
27
+ const parsed = statSchema.safeParse(value);
28
+ if (!parsed.success) unsafeArchive("Docker stat header does not match the bounded path-stat contract.");
29
+ return parsed.data;
30
+ }
31
+ function requireStat(stat, path, kind) {
32
+ const type = (stat.mode & MODE_TYPE) >>> 0;
33
+ if (stat.name !== path.slice(path.lastIndexOf("/") + 1) || stat.linkTarget !== "" || type !== (kind === "directory" ? MODE_DIRECTORY : 0)) unsafeArchive("selected paths require the requested regular file or directory without symlink traversal.");
34
+ }
35
+ function sameStat(actual, expected) {
36
+ if (actual.name !== expected.name || actual.size !== expected.size || actual.mode !== expected.mode || actual.mtime !== expected.mtime || actual.linkTarget !== expected.linkTarget) unsafeArchive("selected path metadata changed between stat and archive observations.");
37
+ }
38
+ async function statPath(api, containerId, path, signal, chunkBytes) {
39
+ const result = await api.stream("HEAD", `${DOCKER_API}/containers/${containerId}/archive?path=${encodeURIComponent(path)}`, { signal, chunkBytes }, async (response) => {
40
+ const stat = pathStat(response);
41
+ for await (const chunk of response.body) if (chunk.length) unsafeArchive("HEAD unexpectedly returned a body.");
42
+ return stat;
43
+ });
44
+ return result.value;
45
+ }
46
+ async function selectedPathStat(api, containerId, relative, kind, signal, chunkBytes) {
47
+ const parts = ["workspace", "work", ...relative.split("/")];
48
+ for (let count = 1; count < parts.length; count += 1) {
49
+ const path2 = `/${parts.slice(0, count).join("/")}`;
50
+ requireStat(await statPath(api, containerId, path2, signal, chunkBytes), path2, "directory");
51
+ }
52
+ const path = `/${parts.join("/")}`;
53
+ const stat = await statPath(api, containerId, path, signal, chunkBytes);
54
+ requireStat(stat, path, kind);
55
+ return stat;
56
+ }
57
+ export {
58
+ pathStat,
59
+ requireStat,
60
+ sameStat,
61
+ selectedPathStat,
62
+ statPath
63
+ };
@@ -0,0 +1,28 @@
1
+ import type { SandboxByteSource, SandboxTransferSummary } from '@agen-ai/sandbox';
2
+ export interface TarInputEntry {
3
+ readonly path: string;
4
+ readonly kind: 'file' | 'directory';
5
+ readonly size: number;
6
+ readonly body?: SandboxByteSource;
7
+ readonly mode: number;
8
+ readonly uid: number;
9
+ readonly gid: number;
10
+ }
11
+ export interface TarHeader {
12
+ readonly path: string;
13
+ readonly kind: 'file' | 'directory' | 'pax';
14
+ readonly size: number;
15
+ readonly mode: number;
16
+ }
17
+ export interface TarOutputEntry extends Omit<TarHeader, 'kind'> {
18
+ readonly kind: 'file' | 'directory';
19
+ readonly body: AsyncIterable<Uint8Array>;
20
+ }
21
+ export interface TarOptions {
22
+ readonly signal: AbortSignal;
23
+ readonly chunkBytes: number;
24
+ readonly maxArchiveBytes?: bigint;
25
+ readonly limitCode: 'input_limit' | 'output_limit';
26
+ }
27
+ export type TarSummary = SandboxTransferSummary;
28
+ //# sourceMappingURL=contracts.d.ts.map
File without changes
@@ -0,0 +1,3 @@
1
+ import type { TarOptions, TarOutputEntry, TarSummary } from './contracts.js';
2
+ export declare function readTar(source: AsyncIterable<Uint8Array>, consume: (entry: TarOutputEntry) => Promise<void>, options: TarOptions): Promise<TarSummary>;
3
+ //# sourceMappingURL=decode.d.ts.map