@zhivex-ai/sandbox-docker 0.0.1 → 0.2.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.
Files changed (47) hide show
  1. package/README.md +89 -1
  2. package/dist/docker-client.d.ts +92 -0
  3. package/dist/docker-client.d.ts.map +1 -0
  4. package/dist/docker-client.js +464 -0
  5. package/dist/docker-client.js.map +1 -0
  6. package/dist/environment.d.ts +119 -0
  7. package/dist/environment.d.ts.map +1 -0
  8. package/dist/environment.js +1056 -0
  9. package/dist/environment.js.map +1 -0
  10. package/dist/errors.d.ts +7 -0
  11. package/dist/errors.d.ts.map +1 -0
  12. package/dist/errors.js +9 -0
  13. package/dist/errors.js.map +1 -0
  14. package/dist/index.d.ts +8 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +8 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/memory-docker-client.d.ts +13 -0
  19. package/dist/memory-docker-client.d.ts.map +1 -0
  20. package/dist/memory-docker-client.js +348 -0
  21. package/dist/memory-docker-client.js.map +1 -0
  22. package/dist/network.d.ts +43 -0
  23. package/dist/network.d.ts.map +1 -0
  24. package/dist/network.js +323 -0
  25. package/dist/network.js.map +1 -0
  26. package/dist/paths.d.ts +7 -0
  27. package/dist/paths.d.ts.map +1 -0
  28. package/dist/paths.js +58 -0
  29. package/dist/paths.js.map +1 -0
  30. package/dist/stores.d.ts +42 -0
  31. package/dist/stores.d.ts.map +1 -0
  32. package/dist/stores.js +396 -0
  33. package/dist/stores.js.map +1 -0
  34. package/dist/stream.d.ts +11 -0
  35. package/dist/stream.d.ts.map +1 -0
  36. package/dist/stream.js +75 -0
  37. package/dist/stream.js.map +1 -0
  38. package/dist/tar.d.ts +13 -0
  39. package/dist/tar.d.ts.map +1 -0
  40. package/dist/tar.js +230 -0
  41. package/dist/tar.js.map +1 -0
  42. package/dist/tools.d.ts +35 -0
  43. package/dist/tools.d.ts.map +1 -0
  44. package/dist/tools.js +90 -0
  45. package/dist/tools.js.map +1 -0
  46. package/docker/Dockerfile +11 -0
  47. package/package.json +46 -4
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
1
  # @zhivex-ai/sandbox-docker
2
2
 
3
- Placeholder release. See https://github.com/Zhivex/zhivex-ai-sdk
3
+ Experimental reference `executionEnvironment` for Zhivex workspace agents. Each agent run gets its own Docker container. The package stays separate from `@zhivex-ai/core` and `@zhivex-ai/sdk` so the shared contract does not depend on a container engine.
4
+
5
+ This is a container boundary you run yourself. It is not a hosted sandbox, a microVM, or a local Unix fallback.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add @zhivex-ai/sandbox-docker @zhivex-ai/sdk
11
+ ```
12
+
13
+ The Docker daemon must be reachable at `/var/run/docker.sock`, or pass `socketPath`. The default image is `alpine:3.21`. `packages/sandbox-docker/docker/Dockerfile` is an optional image if you want uid `1000` instead of the default numeric user `65534:65534`.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { Agent } from "@zhivex-ai/sdk";
19
+ import { createDockerSandboxEnvironment, createDockerSandboxTools } from "@zhivex-ai/sandbox-docker";
20
+
21
+ const sandbox = createDockerSandboxEnvironment({
22
+ workspace: {
23
+ id: "repo",
24
+ files: [{ path: "README.md", contents: "# workspace\n" }],
25
+ directories: ["src"]
26
+ },
27
+ shell: { allow: ["echo", "ls"] }
28
+ });
29
+
30
+ const agent = new Agent({
31
+ model,
32
+ executionEnvironment: sandbox,
33
+ tools: createDockerSandboxTools(),
34
+ maxSteps: 4
35
+ });
36
+ ```
37
+
38
+ `createDockerSandboxTools()` adds `sandbox_read_file`, `sandbox_write_file`, `sandbox_list`, and `sandbox_exec`. Writes and shell commands require interrupt approval. The tools run only against the session the runtime acquired for that run.
39
+
40
+ Pass the same environment object, or the same Docker client plus lease and snapshot stores, when the application resumes a run. The runtime releases the session at the end of every invocation. `waiting_approval`, `suspended`, `running`, `queued`, and `cancel_requested` keep the container and refresh the workspace snapshot. `completed`, `failed`, `cancelled`, and `timed_out` remove the container, the snapshot, and any allowlist network.
41
+
42
+ A changed environment id, workspace id, or manifest fingerprint throws `ConflictError`. An expired lease is removed and is not replaced with an empty workspace. A lost container is restored from the workspace tar when a snapshot exists.
43
+
44
+ ## Security defaults
45
+
46
+ `dockerSandboxSecurityDefaults` is the reference profile:
47
+
48
+ | Control | Default |
49
+ | --- | --- |
50
+ | Image | `alpine:3.21` |
51
+ | User | `65534:65534` (numeric, non-root; root is rejected) |
52
+ | Workspace | `/workspace` tmpfs, files and directories declared up front |
53
+ | Network | `none` |
54
+ | Root filesystem | read-only |
55
+ | Memory | 256 MiB, swap disabled |
56
+ | CPU | 0.5 |
57
+ | Process limit | 64 |
58
+ | Capabilities | drop `ALL`, `no-new-privileges` |
59
+ | Shell | denied until an allowlist or `shell: "allow"` is set |
60
+ | Command timeout | 10 seconds |
61
+ | Output cap | 1 MiB per stdout and stderr |
62
+ | File cap | 1 MiB |
63
+ | Workspace cap | 64 MiB |
64
+ | Lease | 15 minutes |
65
+ | Snapshot | workspace tar |
66
+ | Symlinks | not followed; `realpath` must stay inside `/workspace` |
67
+ | Undeclared tools | denied |
68
+ | Inherited `process.env` | none |
69
+ | Local Unix fallback | not implemented |
70
+
71
+ Host source trees are copied from `workspace.source.include`. They are not bind-mounted. Each file is measured before it is read, and copying stops when the file or workspace budget is exhausted. Shell commands are argv only: a bare name or an executable in `/bin` or `/usr/bin`, with no `..` and no leading `-`. Each command runs under `/usr/bin/timeout -s KILL` in its own session, and the runtime kills that process group when the command returns, times out, is truncated, or is cancelled.
72
+
73
+ Docker's archive API does not read or write the workspace tmpfs on a read-only root filesystem. Copies in and out of `/workspace` therefore use `/bin/tar` inside the container. The default `alpine:3.21` image provides that binary.
74
+
75
+ ## Network allowlist
76
+
77
+ `network: { mode: "allowlist", allowedDomains, allowedPorts }` is opt-in. The container joins an internal Docker network and receives `HTTP_PROXY` / `HTTPS_PROXY` aimed at an in-process CONNECT proxy. The proxy allows only the declared public DNS names and ports. It resolves each CONNECT target twice and connects to the checked address. Private, loopback, link-local, metadata, documentation, and non-global addresses are rejected unless `allowPrivateNetworks` is set, including IPv4-mapped, IPv4-compatible, NAT64, and 6to4 forms.
78
+
79
+ Clients that open sockets directly have no external route. Proxy-aware clients are filtered. The proxy process shares the host network namespace, so a container that can reach the gateway address can also attempt other ports on that gateway. Treat the allowlist as a fail-closed filter for proxy traffic, not as a microVM boundary. Each proxy also enforces connection, in-flight DNS, request, byte, idle, and tunnel-lifetime quotas (`allowlistProxyQuotaDefaults`). Those quotas are part of the manifest fingerprint. The default remains `network: { mode: "deny" }`.
80
+
81
+ ## Snapshots
82
+
83
+ The workspace lives on a tmpfs, so `docker commit` does not contain it. The default snapshot is a tar of `/workspace`. `snapshot: "docker-commit"` also commits an image and still stores the tar, which is what restore uses. Replacing a snapshot keeps every previous image id until that image is deleted, and a later snapshot or sweep retries the deletion. Terminal release and lease expiry delete the image before removing the snapshot record. An image that Docker already reports as missing is treated as deleted.
84
+
85
+ `sweep()` expires leases this process owns and removes only containers labeled with this environment's owner id and manifest fingerprint. Pass the same `ownerId` when several processes share one lease store and should clean up each other's orphans. A different owner or fingerprint is left alone.
86
+
87
+ CPU quota, PID limit, and the numeric user are part of the manifest fingerprint. Resume fails when those limits change, and a running container whose Docker HostConfig disagrees with the current limits is rejected. File snapshot keys reject run ids `.` and `..`.
88
+
89
+ ## Assurance
90
+
91
+ `assurance: "enforced"` is the default for the real Docker client. The in-memory client used by tests and the deterministic example is a double: it refuses `assurance: "enforced"` and records `best-effort` unless you override it. `undeclaredTools: "allow"` permits other in-process tools and does not jail them.
@@ -0,0 +1,92 @@
1
+ export interface DockerPathStat {
2
+ name: string;
3
+ size: number;
4
+ mode: number;
5
+ linkTarget: string;
6
+ }
7
+ export interface DockerCreateSpec {
8
+ image: string;
9
+ user: string;
10
+ workingDir: string;
11
+ env: string[];
12
+ cmd: string[];
13
+ networkMode: string;
14
+ readonlyRootfs: boolean;
15
+ memoryBytes: number;
16
+ nanoCpus: number;
17
+ pidsLimit: number;
18
+ tmpfs: Record<string, string>;
19
+ labels: Record<string, string>;
20
+ init: boolean;
21
+ }
22
+ export interface DockerExecOptions {
23
+ cmd: string[];
24
+ user: string;
25
+ env?: string[];
26
+ workingDir: string;
27
+ timeoutMs: number;
28
+ maxOutputBytes: number;
29
+ signal?: AbortSignal;
30
+ /** Internal kill helper must not recurse into another terminate attempt. */
31
+ skipTerminate?: boolean;
32
+ marker?: string;
33
+ }
34
+ export interface DockerExecResult {
35
+ exitCode: number;
36
+ stdout: Uint8Array;
37
+ stderr: Uint8Array;
38
+ truncated: boolean;
39
+ timedOut: boolean;
40
+ }
41
+ export interface DockerContainerInspection {
42
+ Id: string;
43
+ State: {
44
+ Running: boolean;
45
+ Status: string;
46
+ OOMKilled: boolean;
47
+ ExitCode: number;
48
+ };
49
+ HostConfig?: {
50
+ NanoCpus?: number;
51
+ PidsLimit?: number;
52
+ Memory?: number;
53
+ };
54
+ }
55
+ export interface DockerListedContainer {
56
+ Id: string;
57
+ Labels: Record<string, string>;
58
+ }
59
+ export interface DockerClient {
60
+ readonly kind: "node" | "memory";
61
+ createContainer(spec: DockerCreateSpec): Promise<{
62
+ Id: string;
63
+ }>;
64
+ startContainer(id: string): Promise<void>;
65
+ inspectContainer(id: string): Promise<DockerContainerInspection>;
66
+ removeContainer(id: string): Promise<void>;
67
+ killContainer(id: string, signal?: string): Promise<void>;
68
+ listContainers(label: string): Promise<DockerListedContainer[]>;
69
+ putArchive(id: string, containerPath: string, tar: Uint8Array): Promise<void>;
70
+ getArchive(id: string, containerPath: string, maxOutputBytes?: number): Promise<{
71
+ tar: Uint8Array;
72
+ stat: DockerPathStat;
73
+ }>;
74
+ exec(id: string, options: DockerExecOptions): Promise<DockerExecResult>;
75
+ /** Canonicalize a container path. Callers compare it with the lexical path and refuse escapes. */
76
+ realpath(id: string, containerPath: string): Promise<string>;
77
+ commitContainer(id: string, repo: string, tag: string, labels?: Record<string, string>): Promise<{
78
+ Id: string;
79
+ }>;
80
+ removeImage(id: string): Promise<void>;
81
+ createNetwork(name: string): Promise<{
82
+ Id: string;
83
+ gateway?: string;
84
+ }>;
85
+ removeNetwork(id: string): Promise<void>;
86
+ }
87
+ export interface NodeDockerClientOptions {
88
+ socketPath?: string;
89
+ apiVersion?: string;
90
+ }
91
+ export declare const createNodeDockerClient: (options?: NodeDockerClientOptions) => DockerClient;
92
+ //# sourceMappingURL=docker-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"docker-client.d.ts","sourceRoot":"","sources":["../src/docker-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,464 @@
1
+ import http from "node:http";
2
+ import { SandboxError } from "./errors.js";
3
+ import { readMultiplexedStream } from "./stream.js";
4
+ import { unpackTar } from "./tar.js";
5
+ const empty = new Uint8Array();
6
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
7
+ const TAR_EXTRACT_DEADLINE_MS = 30_000;
8
+ const clampTimeout = (timeoutMs) => {
9
+ if (!Number.isFinite(timeoutMs))
10
+ return 1;
11
+ return Math.min(MAX_TIMER_DELAY_MS, Math.max(1, Math.floor(timeoutMs)));
12
+ };
13
+ export const createNodeDockerClient = (options = {}) => {
14
+ const socketPath = options.socketPath ?? "/var/run/docker.sock";
15
+ let apiVersion = options.apiVersion;
16
+ const prefix = () => `/${apiVersion}`;
17
+ const request = (method, requestPath, body, headers, signal) => new Promise((resolve, reject) => {
18
+ const payload = body ? Buffer.from(body) : undefined;
19
+ const req = http.request({
20
+ socketPath,
21
+ method,
22
+ path: requestPath,
23
+ headers: {
24
+ ...(payload ? { "Content-Type": "application/json", "Content-Length": String(payload.length) } : {}),
25
+ ...headers
26
+ },
27
+ signal
28
+ }, response => {
29
+ const chunks = [];
30
+ response.on("data", chunk => chunks.push(Buffer.from(chunk)));
31
+ response.on("end", () => resolve({
32
+ status: response.statusCode ?? 0,
33
+ headers: response.headers,
34
+ body: Buffer.concat(chunks)
35
+ }));
36
+ });
37
+ req.on("error", error => {
38
+ const code = error.code;
39
+ if (code === "ENOENT" || code === "ECONNREFUSED") {
40
+ reject(new SandboxError("DOCKER_UNAVAILABLE", `Docker socket ${socketPath} is not available.`, { cause: error }));
41
+ return;
42
+ }
43
+ reject(error);
44
+ });
45
+ req.end(payload);
46
+ });
47
+ const ensureVersion = async () => {
48
+ if (apiVersion)
49
+ return;
50
+ const response = await request("GET", "/version");
51
+ if (response.status >= 300) {
52
+ throw new SandboxError("DOCKER_API", dockerMessage(response.body) || "Docker /version failed.");
53
+ }
54
+ const info = JSON.parse(response.body.toString("utf8"));
55
+ if (!info.ApiVersion || !/^\d+\.\d+$/.test(info.ApiVersion)) {
56
+ throw new SandboxError("DOCKER_API", "Docker /version did not report an API version.");
57
+ }
58
+ apiVersion = `v${info.ApiVersion}`;
59
+ };
60
+ const requestJson = async (method, requestPath, body, signal) => {
61
+ await ensureVersion();
62
+ const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
63
+ const response = await request(method, `${prefix()}${requestPath}`, payload, undefined, signal);
64
+ if (response.status === 404) {
65
+ throw new SandboxError("NOT_FOUND", dockerMessage(response.body) || `Docker resource was not found: ${requestPath}`);
66
+ }
67
+ if (response.status >= 300) {
68
+ throw new SandboxError("DOCKER_API", dockerMessage(response.body) || `Docker API ${method} ${requestPath} returned ${response.status}.`);
69
+ }
70
+ if (response.body.length === 0)
71
+ return undefined;
72
+ return JSON.parse(response.body.toString("utf8"));
73
+ };
74
+ const terminate = async (id, marker, user) => {
75
+ const top = await requestJson("GET", `/containers/${id}/top?ps_args=${encodeURIComponent("e -o pid,pgid,args")}`);
76
+ const titles = top.Titles ?? [];
77
+ const pidIndex = titles.findIndex(title => title.toUpperCase() === "PID");
78
+ const pgidIndex = titles.findIndex(title => title.toUpperCase() === "PGID");
79
+ const argsIndex = Math.max(0, titles.findIndex(title => /cmd|command|args/i.test(title)));
80
+ const groups = new Set();
81
+ for (const row of top.Processes ?? []) {
82
+ if (!(row[argsIndex] ?? "").includes(marker))
83
+ continue;
84
+ const pgid = pgidIndex === -1 ? row[pidIndex === -1 ? 0 : pidIndex] : row[pgidIndex];
85
+ if (!pgid || pgid === "0" || pgid === "1")
86
+ continue;
87
+ groups.add(`-${pgid}`);
88
+ }
89
+ if (!groups.size)
90
+ return;
91
+ await ensureVersion();
92
+ await createNodeDockerClient({ socketPath, apiVersion }).exec(id, {
93
+ cmd: ["/bin/kill", "-KILL", "--", ...groups],
94
+ user,
95
+ workingDir: "/",
96
+ timeoutMs: 2_000,
97
+ maxOutputBytes: 1_024,
98
+ skipTerminate: true
99
+ });
100
+ };
101
+ return {
102
+ kind: "node",
103
+ async createContainer(spec) {
104
+ return requestJson("POST", "/containers/create", {
105
+ Image: spec.image,
106
+ User: spec.user,
107
+ WorkingDir: spec.workingDir,
108
+ Env: spec.env,
109
+ Cmd: spec.cmd,
110
+ Labels: spec.labels,
111
+ HostConfig: {
112
+ NetworkMode: spec.networkMode,
113
+ ReadonlyRootfs: spec.readonlyRootfs,
114
+ Memory: spec.memoryBytes,
115
+ MemorySwap: spec.memoryBytes,
116
+ NanoCpus: spec.nanoCpus,
117
+ PidsLimit: spec.pidsLimit,
118
+ CapDrop: ["ALL"],
119
+ SecurityOpt: ["no-new-privileges:true"],
120
+ Init: spec.init,
121
+ Tmpfs: spec.tmpfs
122
+ }
123
+ });
124
+ },
125
+ async startContainer(id) {
126
+ await requestJson("POST", `/containers/${id}/start`);
127
+ },
128
+ async inspectContainer(id) {
129
+ return requestJson("GET", `/containers/${id}/json`);
130
+ },
131
+ async removeContainer(id) {
132
+ await requestJson("DELETE", `/containers/${id}?force=true`);
133
+ },
134
+ async killContainer(id, signal = "SIGKILL") {
135
+ await requestJson("POST", `/containers/${id}/kill?signal=${encodeURIComponent(signal)}`);
136
+ },
137
+ async listContainers(label) {
138
+ const filters = encodeURIComponent(JSON.stringify({ label: [label] }));
139
+ const listed = await requestJson("GET", `/containers/json?all=true&filters=${filters}`);
140
+ return listed.map(container => ({ Id: container.Id, Labels: container.Labels ?? {} }));
141
+ },
142
+ async putArchive(id, containerPath, tar) {
143
+ await ensureVersion();
144
+ const response = await request("PUT", `${prefix()}/containers/${id}/archive?path=${encodeURIComponent(containerPath)}`, tar, {
145
+ "Content-Type": "application/x-tar",
146
+ "Content-Length": String(tar.byteLength)
147
+ });
148
+ if (response.status < 300)
149
+ return;
150
+ const message = dockerMessage(response.body) || `Docker archive upload returned ${response.status}.`;
151
+ if (!/read-only/i.test(message))
152
+ throw new SandboxError("DOCKER_API", message);
153
+ await extractTar(socketPath, prefix(), id, containerPath, tar);
154
+ },
155
+ async getArchive(id, containerPath, maxOutputBytes = 256 * 1024 * 1024) {
156
+ const slash = containerPath.lastIndexOf("/");
157
+ const parent = slash <= 0 ? "/" : containerPath.slice(0, slash);
158
+ const name = containerPath.slice(slash + 1);
159
+ if (!name || name === "." || name === ".." || name.includes("/")) {
160
+ throw new SandboxError("PATH_INVALID", `Sandbox archive path ${containerPath} is not a single container path.`);
161
+ }
162
+ const archived = await this.exec(id, {
163
+ cmd: ["/bin/tar", "-c", "-C", parent, name],
164
+ user: "",
165
+ workingDir: "/",
166
+ timeoutMs: 30_000,
167
+ maxOutputBytes,
168
+ skipTerminate: true
169
+ });
170
+ if (archived.timedOut || archived.truncated) {
171
+ throw new SandboxError("LIMIT", "Sandbox archive exceeds the output cap.");
172
+ }
173
+ if (archived.exitCode !== 0) {
174
+ const detail = new TextDecoder().decode(archived.stderr).trim();
175
+ throw new SandboxError("NOT_FOUND", detail || `Sandbox path ${containerPath} was not found.`);
176
+ }
177
+ const entries = unpackTar(archived.stdout);
178
+ const root = entries.find(entry => entry.path === name);
179
+ return {
180
+ tar: archived.stdout,
181
+ stat: {
182
+ name,
183
+ size: root?.content?.byteLength ?? 0,
184
+ mode: root?.mode ?? 0,
185
+ linkTarget: root?.type === "symlink" ? root.linkTarget ?? "" : ""
186
+ }
187
+ };
188
+ },
189
+ async exec(id, options) {
190
+ const marker = options.marker ?? "";
191
+ const created = await requestJson("POST", `/containers/${id}/exec`, {
192
+ AttachStdout: true,
193
+ AttachStderr: true,
194
+ Tty: false,
195
+ ...(options.user ? { User: options.user } : {}),
196
+ WorkingDir: options.workingDir,
197
+ Env: options.env,
198
+ Cmd: options.cmd
199
+ });
200
+ const controller = new AbortController();
201
+ const timer = setTimeout(() => controller.abort(), clampTimeout(options.timeoutMs));
202
+ const onAbort = () => controller.abort();
203
+ options.signal?.addEventListener("abort", onAbort, { once: true });
204
+ let timedOut = false;
205
+ try {
206
+ const streamed = await streamExec(socketPath, prefix(), created.Id, options.maxOutputBytes, controller.signal);
207
+ timedOut = controller.signal.aborted && !options.signal?.aborted;
208
+ const inspected = await requestJson("GET", `/exec/${created.Id}/json`).catch(() => ({ ExitCode: timedOut ? 137 : 1 }));
209
+ if (options.signal?.aborted) {
210
+ throw new SandboxError("CANCELLED", "Sandbox command was cancelled.");
211
+ }
212
+ return {
213
+ exitCode: inspected.ExitCode ?? (timedOut ? 137 : 1),
214
+ stdout: streamed.stdout,
215
+ stderr: streamed.stderr,
216
+ truncated: streamed.truncated,
217
+ timedOut
218
+ };
219
+ }
220
+ finally {
221
+ clearTimeout(timer);
222
+ options.signal?.removeEventListener("abort", onAbort);
223
+ if (!options.skipTerminate && marker) {
224
+ await terminate(id, marker, options.user).catch(() => undefined);
225
+ }
226
+ }
227
+ },
228
+ async realpath(id, containerPath) {
229
+ if (!containerPath.startsWith("/") || containerPath.includes("\0") || containerPath.length > 4096) {
230
+ throw new SandboxError("PATH_INVALID", "Sandbox realpath requires an absolute container path.");
231
+ }
232
+ const result = await this.exec(id, {
233
+ // Alpine busybox links this applet at /usr/bin/realpath. /bin/realpath is absent.
234
+ cmd: ["/usr/bin/realpath", containerPath],
235
+ user: "",
236
+ workingDir: "/",
237
+ timeoutMs: 5_000,
238
+ maxOutputBytes: 8_192,
239
+ skipTerminate: true
240
+ });
241
+ if (result.exitCode !== 0 || result.timedOut || result.truncated) {
242
+ throw new SandboxError("NOT_FOUND", `Sandbox path ${containerPath} was not found.`);
243
+ }
244
+ const resolved = new TextDecoder().decode(result.stdout).replace(/\n$/, "");
245
+ if (!resolved.startsWith("/") || resolved.includes("\0")) {
246
+ throw new SandboxError("PATH_INVALID", "Sandbox realpath returned an unusable path.");
247
+ }
248
+ return resolved;
249
+ },
250
+ async commitContainer(id, repo, tag, labels) {
251
+ return requestJson("POST", `/commit?container=${encodeURIComponent(id)}&repo=${encodeURIComponent(repo)}&tag=${encodeURIComponent(tag)}`, labels ? { Labels: labels } : undefined);
252
+ },
253
+ async removeImage(id) {
254
+ await requestJson("DELETE", `/images/${encodeURIComponent(id)}?force=true`);
255
+ },
256
+ async createNetwork(name) {
257
+ const created = await requestJson("POST", "/networks/create", {
258
+ Name: name,
259
+ Internal: true,
260
+ CheckDuplicate: true,
261
+ Labels: { "zhivex.sandbox": "1" }
262
+ });
263
+ const inspected = await requestJson("GET", `/networks/${created.Id}`);
264
+ return { Id: created.Id, gateway: inspected.IPAM?.Config?.[0]?.Gateway };
265
+ },
266
+ async removeNetwork(id) {
267
+ await requestJson("DELETE", `/networks/${id}`);
268
+ }
269
+ };
270
+ };
271
+ const extractTar = (socketPath, apiPrefix, id, containerPath, tar) => new Promise((resolve, reject) => {
272
+ const controller = new AbortController();
273
+ const timer = setTimeout(() => controller.abort(), TAR_EXTRACT_DEADLINE_MS);
274
+ let settled = false;
275
+ const finish = (error) => {
276
+ if (settled)
277
+ return;
278
+ settled = true;
279
+ clearTimeout(timer);
280
+ if (error)
281
+ reject(error instanceof Error ? error : new Error(String(error)));
282
+ else
283
+ resolve();
284
+ };
285
+ controller.signal.addEventListener("abort", () => {
286
+ finish(new SandboxError("LIMIT", "Sandbox tar extract exceeded its deadline."));
287
+ }, { once: true });
288
+ const createBody = Buffer.from(JSON.stringify({
289
+ AttachStdin: true,
290
+ AttachStdout: true,
291
+ AttachStderr: true,
292
+ Tty: false,
293
+ Cmd: ["/bin/tar", "-x", "-C", containerPath]
294
+ }));
295
+ const create = http.request({
296
+ socketPath,
297
+ method: "POST",
298
+ path: `${apiPrefix}/containers/${id}/exec`,
299
+ headers: { "Content-Type": "application/json", "Content-Length": String(createBody.length) },
300
+ signal: controller.signal
301
+ }, response => {
302
+ const chunks = [];
303
+ response.on("data", chunk => chunks.push(Buffer.from(chunk)));
304
+ response.on("end", () => {
305
+ const body = Buffer.concat(chunks);
306
+ if ((response.statusCode ?? 500) >= 300) {
307
+ finish(new SandboxError("DOCKER_API", dockerMessage(body) || "Docker tar exec create failed."));
308
+ return;
309
+ }
310
+ const created = JSON.parse(body.toString("utf8"));
311
+ if (!created.Id) {
312
+ finish(new SandboxError("DOCKER_API", "Docker tar exec did not return an id."));
313
+ return;
314
+ }
315
+ startTarExtract(socketPath, apiPrefix, created.Id, tar, controller.signal).then(() => finish(), finish);
316
+ });
317
+ });
318
+ create.on("error", error => {
319
+ if (controller.signal.aborted)
320
+ finish(new SandboxError("LIMIT", "Sandbox tar extract exceeded its deadline."));
321
+ else
322
+ finish(error);
323
+ });
324
+ create.end(createBody);
325
+ });
326
+ const startTarExtract = (socketPath, apiPrefix, execId, tar, signal) => new Promise((resolve, reject) => {
327
+ let settled = false;
328
+ const finish = (error) => {
329
+ if (settled)
330
+ return;
331
+ settled = true;
332
+ if (error)
333
+ reject(error instanceof Error ? error : new Error(String(error)));
334
+ else
335
+ resolve();
336
+ };
337
+ const payload = Buffer.from(JSON.stringify({ Detach: false, Tty: false }));
338
+ const req = http.request({
339
+ socketPath,
340
+ method: "POST",
341
+ path: `${apiPrefix}/exec/${execId}/start`,
342
+ headers: {
343
+ "Content-Type": "application/json",
344
+ Connection: "Upgrade",
345
+ Upgrade: "tcp",
346
+ "Content-Length": String(payload.length)
347
+ },
348
+ signal
349
+ });
350
+ let upgraded;
351
+ const fail = (error) => {
352
+ upgraded?.destroy();
353
+ req.destroy();
354
+ finish(error);
355
+ };
356
+ signal.addEventListener("abort", () => fail(new SandboxError("LIMIT", "Sandbox tar extract exceeded its deadline.")), { once: true });
357
+ req.on("upgrade", (_response, socket) => {
358
+ upgraded = socket;
359
+ const output = [];
360
+ socket.on("data", chunk => output.push(Buffer.from(chunk)));
361
+ socket.on("error", fail);
362
+ socket.on("end", () => {
363
+ inspectExec(socketPath, apiPrefix, execId, signal).then(exitCode => {
364
+ if (exitCode !== 0) {
365
+ fail(new SandboxError("DOCKER_API", `Sandbox tar extract exited ${exitCode}: ${Buffer.concat(output).toString("utf8").slice(0, 300)}`));
366
+ return;
367
+ }
368
+ finish();
369
+ }).catch(fail);
370
+ });
371
+ socket.end(tar);
372
+ });
373
+ req.on("response", response => {
374
+ const chunks = [];
375
+ response.on("data", chunk => chunks.push(Buffer.from(chunk)));
376
+ response.on("end", () => fail(new SandboxError("DOCKER_API", dockerMessage(Buffer.concat(chunks)) || "Docker tar extract did not upgrade the connection.")));
377
+ });
378
+ req.on("error", error => {
379
+ if (signal.aborted)
380
+ fail(new SandboxError("LIMIT", "Sandbox tar extract exceeded its deadline."));
381
+ else
382
+ fail(error);
383
+ });
384
+ req.end(payload);
385
+ });
386
+ const inspectExec = (socketPath, apiPrefix, execId, signal) => new Promise((resolve, reject) => {
387
+ const req = http.request({
388
+ socketPath,
389
+ method: "GET",
390
+ path: `${apiPrefix}/exec/${execId}/json`,
391
+ signal
392
+ }, response => {
393
+ const chunks = [];
394
+ response.on("data", chunk => chunks.push(Buffer.from(chunk)));
395
+ response.on("end", () => {
396
+ const body = Buffer.concat(chunks);
397
+ if ((response.statusCode ?? 500) >= 300) {
398
+ reject(new SandboxError("DOCKER_API", dockerMessage(body) || "Docker exec inspect failed."));
399
+ return;
400
+ }
401
+ const inspected = JSON.parse(body.toString("utf8"));
402
+ resolve(inspected.ExitCode ?? 1);
403
+ });
404
+ });
405
+ req.on("error", reject);
406
+ req.end();
407
+ });
408
+ const dockerMessage = (body) => {
409
+ try {
410
+ const parsed = JSON.parse(body.toString("utf8"));
411
+ return parsed.message ?? "";
412
+ }
413
+ catch {
414
+ return body.toString("utf8").slice(0, 300);
415
+ }
416
+ };
417
+ const streamExec = (socketPath, prefix, execId, maxBytes, signal) => new Promise((resolve, reject) => {
418
+ let settled = false;
419
+ const finish = (value) => {
420
+ if (settled)
421
+ return;
422
+ settled = true;
423
+ resolve(value);
424
+ };
425
+ const fail = (error) => {
426
+ if (settled)
427
+ return;
428
+ settled = true;
429
+ reject(error);
430
+ };
431
+ const payload = Buffer.from(JSON.stringify({ Detach: false, Tty: false }));
432
+ const req = http.request({
433
+ socketPath,
434
+ method: "POST",
435
+ path: `${prefix}/exec/${execId}/start`,
436
+ headers: { "Content-Type": "application/json", "Content-Length": String(payload.length) },
437
+ signal
438
+ }, response => {
439
+ if ((response.statusCode ?? 500) >= 300) {
440
+ const chunks = [];
441
+ response.on("data", chunk => chunks.push(Buffer.from(chunk)));
442
+ response.on("end", () => fail(new SandboxError("DOCKER_API", dockerMessage(Buffer.concat(chunks)) || "Docker exec start failed.")));
443
+ return;
444
+ }
445
+ void readMultiplexedStream(response, maxBytes).then(result => {
446
+ if (result.truncated)
447
+ response.destroy();
448
+ finish(result);
449
+ }).catch(error => {
450
+ if (signal.aborted)
451
+ finish({ stdout: empty, stderr: empty, truncated: false });
452
+ else
453
+ fail(error);
454
+ });
455
+ });
456
+ req.on("error", error => {
457
+ if (signal.aborted)
458
+ finish({ stdout: empty, stderr: empty, truncated: false });
459
+ else
460
+ fail(error);
461
+ });
462
+ req.end(payload);
463
+ });
464
+ //# sourceMappingURL=docker-client.js.map