@zhivex-ai/sandbox-docker 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -1
- package/dist/docker-client.d.ts +92 -0
- package/dist/docker-client.d.ts.map +1 -0
- package/dist/docker-client.js +464 -0
- package/dist/docker-client.js.map +1 -0
- package/dist/environment.d.ts +119 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +1056 -0
- package/dist/environment.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/memory-docker-client.d.ts +13 -0
- package/dist/memory-docker-client.d.ts.map +1 -0
- package/dist/memory-docker-client.js +348 -0
- package/dist/memory-docker-client.js.map +1 -0
- package/dist/network.d.ts +43 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +323 -0
- package/dist/network.js.map +1 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +58 -0
- package/dist/paths.js.map +1 -0
- package/dist/stores.d.ts +42 -0
- package/dist/stores.d.ts.map +1 -0
- package/dist/stores.js +396 -0
- package/dist/stores.js.map +1 -0
- package/dist/stream.d.ts +11 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +75 -0
- package/dist/stream.js.map +1 -0
- package/dist/tar.d.ts +13 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +230 -0
- package/dist/tar.js.map +1 -0
- package/dist/tools.d.ts +35 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +90 -0
- package/dist/tools.js.map +1 -0
- package/docker/Dockerfile +11 -0
- package/package.json +46 -4
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, readdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ConflictError, createAgentExecutionEnvironmentBinding } from "@zhivex-ai/core";
|
|
6
|
+
import { createNodeDockerClient } from "./docker-client.js";
|
|
7
|
+
import { SandboxError } from "./errors.js";
|
|
8
|
+
import { allowlistProxyQuotaDefaults, normalizeAllowlist, startAllowlistProxy } from "./network.js";
|
|
9
|
+
import { assertRelativeWorkspacePath, assertWorkspaceRoot, commandBaseName, resolveWorkspacePath } from "./paths.js";
|
|
10
|
+
import { createMemorySandboxLeaseStore, createMemorySandboxSnapshotStore } from "./stores.js";
|
|
11
|
+
import { decodeCappedText } from "./stream.js";
|
|
12
|
+
import { packTar, stripTarRoot, unpackTar } from "./tar.js";
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
let sourceBytesRead = 0;
|
|
15
|
+
/** Bytes actually read while importing a host source tree. Tests use this to prove early aborts. */
|
|
16
|
+
export const sandboxSourceBytesRead = () => sourceBytesRead;
|
|
17
|
+
export const dockerSandboxSecurityDefaults = Object.freeze({
|
|
18
|
+
image: "alpine:3.21",
|
|
19
|
+
user: "65534:65534",
|
|
20
|
+
workspaceRoot: "/workspace",
|
|
21
|
+
network: "deny",
|
|
22
|
+
assurance: "enforced",
|
|
23
|
+
isolation: "per-run",
|
|
24
|
+
memoryMb: 256,
|
|
25
|
+
cpus: 0.5,
|
|
26
|
+
pidsLimit: 64,
|
|
27
|
+
maxProcessRuntimeMs: 10_000,
|
|
28
|
+
maxProcessOutputBytes: 1_048_576,
|
|
29
|
+
maxFileBytes: 1_048_576,
|
|
30
|
+
maxWorkspaceBytes: 64 * 1024 * 1024,
|
|
31
|
+
leaseTtlMs: 15 * 60 * 1000,
|
|
32
|
+
readonlyRootfs: true,
|
|
33
|
+
droppedCapabilities: "ALL",
|
|
34
|
+
noNewPrivileges: true,
|
|
35
|
+
snapshot: "workspace-tar",
|
|
36
|
+
shell: "deny",
|
|
37
|
+
inheritedEnvironment: [],
|
|
38
|
+
followSymlinks: false,
|
|
39
|
+
undeclaredTools: "deny",
|
|
40
|
+
localUnixFallback: false
|
|
41
|
+
});
|
|
42
|
+
const terminalStatus = new Set(["completed", "failed", "cancelled", "timed_out"]);
|
|
43
|
+
const scopeKeyOf = (scope) => JSON.stringify([scope?.tenantId ?? "", scope?.userId ?? "", scope?.namespace ?? ""]);
|
|
44
|
+
const integerLimit = (value, fallback, name, max) => {
|
|
45
|
+
const resolved = value ?? fallback;
|
|
46
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > max) {
|
|
47
|
+
throw new SandboxError("LIMIT", `${name} must be an integer from 1 to ${max}.`);
|
|
48
|
+
}
|
|
49
|
+
return resolved;
|
|
50
|
+
};
|
|
51
|
+
const parseUser = (user) => {
|
|
52
|
+
const match = /^([1-9]\d*):([1-9]\d*)$/.exec(user);
|
|
53
|
+
const uid = Number(match?.[1]);
|
|
54
|
+
const gid = Number(match?.[2]);
|
|
55
|
+
if (!match || !Number.isSafeInteger(uid) || !Number.isSafeInteger(gid)) {
|
|
56
|
+
throw new SandboxError("USER", "Sandbox user must be a non-root numeric uid:gid, for example 65534:65534.");
|
|
57
|
+
}
|
|
58
|
+
return { user, uid, gid };
|
|
59
|
+
};
|
|
60
|
+
const parseShell = (shell) => {
|
|
61
|
+
if (shell === undefined || shell === "deny")
|
|
62
|
+
return { mode: "deny", commands: [] };
|
|
63
|
+
if (shell === "allow")
|
|
64
|
+
return { mode: "allow", commands: [] };
|
|
65
|
+
const commands = [...new Set(shell.allow.map(commandBaseName))].sort();
|
|
66
|
+
if (commands.length === 0) {
|
|
67
|
+
throw new SandboxError("SHELL_ALLOWLIST_EMPTY", "Shell allowlist requires at least one command name.");
|
|
68
|
+
}
|
|
69
|
+
return { mode: "allowlist", commands };
|
|
70
|
+
};
|
|
71
|
+
const parseEnvironment = (environment, inherited) => {
|
|
72
|
+
const names = [...new Set(inherited)].sort();
|
|
73
|
+
const entries = [];
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) {
|
|
76
|
+
throw new SandboxError("ENVIRONMENT", `Inherited variable "${name}" is not an uppercase environment name.`);
|
|
77
|
+
}
|
|
78
|
+
const value = environment?.[name];
|
|
79
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4096 || /[\0\r\n]/.test(value)) {
|
|
80
|
+
throw new SandboxError("ENVIRONMENT", `Inherited variable "${name}" must be an explicit single-line value.`);
|
|
81
|
+
}
|
|
82
|
+
entries.push(`${name}=${value}`);
|
|
83
|
+
}
|
|
84
|
+
return { names, entries };
|
|
85
|
+
};
|
|
86
|
+
const validateArgv = (argv, shell) => {
|
|
87
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.length > 64) {
|
|
88
|
+
throw new SandboxError("COMMAND_INVALID", "Sandbox commands must be an argv array of 1 to 64 strings.");
|
|
89
|
+
}
|
|
90
|
+
if (shell.mode === "deny") {
|
|
91
|
+
throw new SandboxError("SHELL_DENIED", "Shell execution is disabled for this sandbox.");
|
|
92
|
+
}
|
|
93
|
+
const command = argv.map(part => {
|
|
94
|
+
if (typeof part !== "string" || part.length === 0 || part.length > 4096 || part.includes("\0")) {
|
|
95
|
+
throw new SandboxError("COMMAND_INVALID", "Sandbox command arguments must be non-empty strings without NUL bytes.");
|
|
96
|
+
}
|
|
97
|
+
return part;
|
|
98
|
+
});
|
|
99
|
+
const name = commandBaseName(command[0] ?? "");
|
|
100
|
+
if (shell.mode === "allowlist" && !shell.commands.includes(name)) {
|
|
101
|
+
throw new SandboxError("SHELL_DENIED", `Command "${name}" is not on the sandbox allowlist.`);
|
|
102
|
+
}
|
|
103
|
+
return command;
|
|
104
|
+
};
|
|
105
|
+
export const createDockerSandboxEnvironment = (options) => {
|
|
106
|
+
const docker = options.docker ?? createNodeDockerClient({ socketPath: options.socketPath });
|
|
107
|
+
const user = parseUser(options.user ?? dockerSandboxSecurityDefaults.user);
|
|
108
|
+
const image = options.image ?? dockerSandboxSecurityDefaults.image;
|
|
109
|
+
if (!image.trim() || /\s/.test(image)) {
|
|
110
|
+
throw new SandboxError("IMAGE", "Sandbox image must be a non-empty image reference without whitespace.");
|
|
111
|
+
}
|
|
112
|
+
const access = options.workspace.access ?? "read-write";
|
|
113
|
+
const shell = parseShell(options.shell);
|
|
114
|
+
if (access === "read-only" && shell.mode !== "deny") {
|
|
115
|
+
throw new SandboxError("READ_ONLY_SHELL", "A read-only workspace cannot enable shell execution. The reference profile cannot remount the workspace after seeding.");
|
|
116
|
+
}
|
|
117
|
+
const network = options.network ?? { mode: "deny" };
|
|
118
|
+
const allowlist = network.mode === "allowlist" ? normalizeAllowlist(network) : undefined;
|
|
119
|
+
const environment = parseEnvironment(options.environment, options.inheritedVariables ?? []);
|
|
120
|
+
const limits = {
|
|
121
|
+
memoryBytes: integerLimit(options.limits?.memoryMb, dockerSandboxSecurityDefaults.memoryMb, "memoryMb", 8192) * 1024 * 1024,
|
|
122
|
+
nanoCpus: Math.round((() => {
|
|
123
|
+
const cpus = options.limits?.cpus ?? dockerSandboxSecurityDefaults.cpus;
|
|
124
|
+
if (!(cpus > 0) || cpus > 16)
|
|
125
|
+
throw new SandboxError("LIMIT", "cpus must be greater than 0 and at most 16.");
|
|
126
|
+
return cpus;
|
|
127
|
+
})() * 1_000_000_000),
|
|
128
|
+
pidsLimit: integerLimit(options.limits?.pidsLimit, dockerSandboxSecurityDefaults.pidsLimit, "pidsLimit", 1024),
|
|
129
|
+
maxProcessRuntimeMs: integerLimit(options.limits?.maxProcessRuntimeMs, dockerSandboxSecurityDefaults.maxProcessRuntimeMs, "maxProcessRuntimeMs", 3_600_000),
|
|
130
|
+
maxProcessOutputBytes: integerLimit(options.limits?.maxProcessOutputBytes, dockerSandboxSecurityDefaults.maxProcessOutputBytes, "maxProcessOutputBytes", 8_388_608),
|
|
131
|
+
maxFileBytes: integerLimit(options.limits?.maxFileBytes, dockerSandboxSecurityDefaults.maxFileBytes, "maxFileBytes", 8_388_608),
|
|
132
|
+
maxWorkspaceBytes: integerLimit(options.limits?.maxWorkspaceBytes, dockerSandboxSecurityDefaults.maxWorkspaceBytes, "maxWorkspaceBytes", 1_073_741_824),
|
|
133
|
+
maxConcurrentProcesses: integerLimit(options.limits?.maxConcurrentProcesses, 1, "maxConcurrentProcesses", 8)
|
|
134
|
+
};
|
|
135
|
+
const assurance = options.assurance ?? (docker.kind === "memory" ? "best-effort" : "enforced");
|
|
136
|
+
if (assurance === "enforced" && docker.kind !== "node") {
|
|
137
|
+
throw new SandboxError("UNENFORCED_DOUBLE", "The in-memory Docker client cannot satisfy assurance: enforced.");
|
|
138
|
+
}
|
|
139
|
+
const workspaceRoot = assertWorkspaceRoot(options.workspace.root ?? dockerSandboxSecurityDefaults.workspaceRoot);
|
|
140
|
+
const declaredPaths = [
|
|
141
|
+
...(options.workspace.files ?? []).map(file => assertRelativeWorkspacePath(file.path)),
|
|
142
|
+
...(options.workspace.directories ?? []).map(assertRelativeWorkspacePath),
|
|
143
|
+
...(options.workspace.source?.include ?? []).map(assertRelativeWorkspacePath)
|
|
144
|
+
].sort();
|
|
145
|
+
const snapshotMode = options.snapshot ?? "workspace-tar";
|
|
146
|
+
const undeclaredTools = options.undeclaredTools ?? "deny";
|
|
147
|
+
const ownerId = options.ownerId ?? randomUUID();
|
|
148
|
+
if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(ownerId)) {
|
|
149
|
+
throw new SandboxError("OWNER", "Sandbox owner id must be 8-128 characters of letters, digits, and . _ : -.");
|
|
150
|
+
}
|
|
151
|
+
const networkQuotas = allowlist ? allowlistProxyQuotaDefaults : undefined;
|
|
152
|
+
const manifest = {
|
|
153
|
+
schemaVersion: 1,
|
|
154
|
+
id: options.id ?? "docker-sandbox",
|
|
155
|
+
version: options.version ?? "1",
|
|
156
|
+
backend: "container",
|
|
157
|
+
assurance,
|
|
158
|
+
isolation: "per-run",
|
|
159
|
+
workspace: {
|
|
160
|
+
id: options.workspace.id,
|
|
161
|
+
root: workspaceRoot,
|
|
162
|
+
cwd: workspaceRoot,
|
|
163
|
+
access,
|
|
164
|
+
followSymlinks: false
|
|
165
|
+
},
|
|
166
|
+
permissions: {
|
|
167
|
+
undeclaredTools,
|
|
168
|
+
filesystem: access === "read-only" ? "read-only" : "read-write",
|
|
169
|
+
network: allowlist
|
|
170
|
+
? {
|
|
171
|
+
mode: "allowlist",
|
|
172
|
+
allowedDomains: allowlist.domains,
|
|
173
|
+
allowedPorts: allowlist.ports,
|
|
174
|
+
allowPrivateNetworks: network.mode === "allowlist" && network.allowPrivateNetworks === true
|
|
175
|
+
}
|
|
176
|
+
: { mode: "deny" },
|
|
177
|
+
process: shell.mode === "allowlist"
|
|
178
|
+
? { shell: "allowlist", allowedCommands: shell.commands }
|
|
179
|
+
: { shell: shell.mode },
|
|
180
|
+
environment: { inheritedVariables: environment.names }
|
|
181
|
+
},
|
|
182
|
+
limits: {
|
|
183
|
+
maxProcessRuntimeMs: limits.maxProcessRuntimeMs,
|
|
184
|
+
maxProcessOutputBytes: limits.maxProcessOutputBytes,
|
|
185
|
+
maxConcurrentProcesses: limits.maxConcurrentProcesses,
|
|
186
|
+
maxMemoryMb: limits.memoryBytes / (1024 * 1024),
|
|
187
|
+
maxWorkspaceBytes: limits.maxWorkspaceBytes,
|
|
188
|
+
maxFileWriteBytes: limits.maxFileBytes,
|
|
189
|
+
...(networkQuotas
|
|
190
|
+
? { maxNetworkRequests: networkQuotas.maxRequests, maxNetworkBytes: networkQuotas.maxBytes }
|
|
191
|
+
: {})
|
|
192
|
+
},
|
|
193
|
+
metadata: {
|
|
194
|
+
sandbox: "docker",
|
|
195
|
+
image,
|
|
196
|
+
user: user.user,
|
|
197
|
+
nanoCpus: limits.nanoCpus,
|
|
198
|
+
pidsLimit: limits.pidsLimit,
|
|
199
|
+
snapshot: snapshotMode,
|
|
200
|
+
declaredPaths,
|
|
201
|
+
...(networkQuotas ? { networkQuotas } : {})
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const workspaceId = manifest.workspace?.id;
|
|
205
|
+
if (!workspaceId?.trim()) {
|
|
206
|
+
throw new SandboxError("WORKSPACE_ID", "Sandbox workspace id must be a non-empty string.");
|
|
207
|
+
}
|
|
208
|
+
const binding = createAgentExecutionEnvironmentBinding(manifest);
|
|
209
|
+
const leases = options.leases ?? createMemorySandboxLeaseStore();
|
|
210
|
+
const snapshots = options.snapshots ?? createMemorySandboxSnapshotStore();
|
|
211
|
+
const ttl = integerLimit(options.leaseTtlMs, dockerSandboxSecurityDefaults.leaseTtlMs, "leaseTtlMs", 86_400_000);
|
|
212
|
+
const clock = options.clock ?? Date.now;
|
|
213
|
+
const proxies = new Map();
|
|
214
|
+
const proxyKey = (runId, scopeKey) => `${scopeKey}\0${runId}`;
|
|
215
|
+
const seedEntries = async (signal) => {
|
|
216
|
+
const budget = { remaining: limits.maxWorkspaceBytes };
|
|
217
|
+
const claim = (bytes, label) => {
|
|
218
|
+
if (bytes.byteLength > limits.maxFileBytes) {
|
|
219
|
+
throw new SandboxError("LIMIT", `Sandbox file ${label} exceeds maxFileBytes.`);
|
|
220
|
+
}
|
|
221
|
+
if (bytes.byteLength > budget.remaining) {
|
|
222
|
+
throw new SandboxError("LIMIT", "Declared workspace files exceed maxWorkspaceBytes.");
|
|
223
|
+
}
|
|
224
|
+
budget.remaining -= bytes.byteLength;
|
|
225
|
+
return bytes;
|
|
226
|
+
};
|
|
227
|
+
const entries = [];
|
|
228
|
+
for (const directory of options.workspace.directories ?? []) {
|
|
229
|
+
throwIfSourceAborted(signal);
|
|
230
|
+
entries.push({ path: assertRelativeWorkspacePath(directory), type: "directory", mode: 0o755, uid: user.uid, gid: user.gid });
|
|
231
|
+
}
|
|
232
|
+
for (const file of options.workspace.files ?? []) {
|
|
233
|
+
throwIfSourceAborted(signal);
|
|
234
|
+
const content = typeof file.contents === "string" ? encoder.encode(file.contents) : file.contents;
|
|
235
|
+
entries.push({
|
|
236
|
+
path: assertRelativeWorkspacePath(file.path),
|
|
237
|
+
type: "file",
|
|
238
|
+
content: claim(content, file.path),
|
|
239
|
+
mode: file.mode ?? 0o644,
|
|
240
|
+
uid: user.uid,
|
|
241
|
+
gid: user.gid
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (options.workspace.source) {
|
|
245
|
+
entries.push(...await readSourceTree(options.workspace.source.root, options.workspace.source.include, limits, user.uid, user.gid, budget, signal));
|
|
246
|
+
}
|
|
247
|
+
const total = entries.reduce((sum, entry) => sum + (entry.content?.byteLength ?? 0), 0);
|
|
248
|
+
if (total > limits.maxWorkspaceBytes) {
|
|
249
|
+
throw new SandboxError("LIMIT", "Declared workspace files exceed maxWorkspaceBytes.");
|
|
250
|
+
}
|
|
251
|
+
return entries;
|
|
252
|
+
};
|
|
253
|
+
const baseEnv = () => [
|
|
254
|
+
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
255
|
+
"HOME=/tmp",
|
|
256
|
+
"TMPDIR=/tmp",
|
|
257
|
+
...environment.entries
|
|
258
|
+
];
|
|
259
|
+
const openNetwork = async (runId, scopeKey) => {
|
|
260
|
+
if (!allowlist || network.mode !== "allowlist")
|
|
261
|
+
return undefined;
|
|
262
|
+
const key = proxyKey(runId, scopeKey);
|
|
263
|
+
const existing = proxies.get(key);
|
|
264
|
+
if (existing)
|
|
265
|
+
return existing;
|
|
266
|
+
const created = await docker.createNetwork(`zhivex_${randomUUID()}`);
|
|
267
|
+
const gateway = created.gateway;
|
|
268
|
+
const listenHost = docker.kind === "memory" ? "127.0.0.1" : gateway;
|
|
269
|
+
if (!listenHost) {
|
|
270
|
+
await docker.removeNetwork(created.Id).catch(() => undefined);
|
|
271
|
+
throw new SandboxError("NETWORK_PROXY", "Allowlist network did not publish a gateway address. Refusing to attach a routed network.");
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const proxy = await startAllowlistProxy({
|
|
275
|
+
host: listenHost,
|
|
276
|
+
quotas: allowlistProxyQuotaDefaults,
|
|
277
|
+
policy: {
|
|
278
|
+
allowedDomains: allowlist.domains,
|
|
279
|
+
allowedPorts: allowlist.ports,
|
|
280
|
+
allowPrivateNetworks: network.allowPrivateNetworks === true
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
const opened = { proxy, networkId: created.Id, gateway: gateway ?? listenHost };
|
|
284
|
+
proxies.set(key, opened);
|
|
285
|
+
return opened;
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
await docker.removeNetwork(created.Id).catch(() => undefined);
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const specFor = (runId, networkMode, proxyUrl) => ({
|
|
293
|
+
image,
|
|
294
|
+
user: user.user,
|
|
295
|
+
workingDir: workspaceRoot,
|
|
296
|
+
env: proxyUrl ? [...baseEnv(), `HTTP_PROXY=${proxyUrl}`, `HTTPS_PROXY=${proxyUrl}`, "NO_PROXY=localhost,127.0.0.1"] : baseEnv(),
|
|
297
|
+
cmd: ["/bin/sh", "-c", "while true; do sleep 3600; done"],
|
|
298
|
+
networkMode,
|
|
299
|
+
readonlyRootfs: true,
|
|
300
|
+
memoryBytes: limits.memoryBytes,
|
|
301
|
+
nanoCpus: limits.nanoCpus,
|
|
302
|
+
pidsLimit: limits.pidsLimit,
|
|
303
|
+
tmpfs: {
|
|
304
|
+
"/tmp": "rw,nosuid,nodev,noexec,size=67108864,mode=1777",
|
|
305
|
+
[workspaceRoot]: `rw,nosuid,nodev,size=${limits.maxWorkspaceBytes},uid=${user.uid},gid=${user.gid},mode=0700`
|
|
306
|
+
},
|
|
307
|
+
labels: {
|
|
308
|
+
"zhivex.sandbox": "1",
|
|
309
|
+
"zhivex.environment-id": manifest.id,
|
|
310
|
+
"zhivex.owner": ownerId,
|
|
311
|
+
"zhivex.run-id": runId,
|
|
312
|
+
"zhivex.fingerprint": binding.fingerprint
|
|
313
|
+
},
|
|
314
|
+
init: true
|
|
315
|
+
});
|
|
316
|
+
const restoreTar = async (containerId, workspaceTar) => {
|
|
317
|
+
const entries = stripTarRoot(unpackTar(workspaceTar), "workspace");
|
|
318
|
+
if (entries.length === 0)
|
|
319
|
+
return;
|
|
320
|
+
await docker.putArchive(containerId, workspaceRoot, packTar(entries));
|
|
321
|
+
};
|
|
322
|
+
const bootContainer = async (runId, scopeKey, previous, workspaceTar, imageId, signal) => {
|
|
323
|
+
const throwIfCancelled = () => {
|
|
324
|
+
if (signal?.aborted)
|
|
325
|
+
throw new SandboxError("CANCELLED", "Sandbox acquire was cancelled.");
|
|
326
|
+
};
|
|
327
|
+
throwIfCancelled();
|
|
328
|
+
const opened = await openNetwork(runId, scopeKey);
|
|
329
|
+
throwIfCancelled();
|
|
330
|
+
const spec = specFor(runId, opened ? opened.networkId : "none", opened ? `http://${opened.gateway}:${opened.proxy.port}` : undefined);
|
|
331
|
+
if (imageId)
|
|
332
|
+
spec.image = imageId;
|
|
333
|
+
let createdId;
|
|
334
|
+
try {
|
|
335
|
+
const created = await docker.createContainer(spec);
|
|
336
|
+
createdId = created.Id;
|
|
337
|
+
await docker.startContainer(created.Id);
|
|
338
|
+
if (workspaceTar)
|
|
339
|
+
await restoreTar(created.Id, workspaceTar);
|
|
340
|
+
else if (!imageId) {
|
|
341
|
+
const entries = withParents(await seedEntries(signal), user.uid, user.gid);
|
|
342
|
+
if (entries.length)
|
|
343
|
+
await docker.putArchive(created.Id, workspaceRoot, packTar(entries));
|
|
344
|
+
}
|
|
345
|
+
throwIfCancelled();
|
|
346
|
+
const now = clock();
|
|
347
|
+
const lease = {
|
|
348
|
+
schemaVersion: 1,
|
|
349
|
+
runId,
|
|
350
|
+
scopeKey,
|
|
351
|
+
environmentId: binding.environmentId,
|
|
352
|
+
fingerprint: binding.fingerprint,
|
|
353
|
+
workspaceId: binding.workspaceId,
|
|
354
|
+
containerId: created.Id,
|
|
355
|
+
networkId: opened?.networkId,
|
|
356
|
+
revision: (previous?.revision ?? -1) + 1,
|
|
357
|
+
status: "active",
|
|
358
|
+
createdAt: previous?.createdAt ?? now,
|
|
359
|
+
expiresAt: now + ttl
|
|
360
|
+
};
|
|
361
|
+
await leases.compareAndSwap(lease, previous?.revision);
|
|
362
|
+
return lease;
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
if (createdId)
|
|
366
|
+
await docker.removeContainer(createdId).catch(() => undefined);
|
|
367
|
+
if (opened && opened.networkId !== previous?.networkId) {
|
|
368
|
+
const current = await leases.load(runId, scopeKey).catch(() => undefined);
|
|
369
|
+
if (current?.networkId !== opened.networkId) {
|
|
370
|
+
if (proxies.get(proxyKey(runId, scopeKey))?.networkId === opened.networkId) {
|
|
371
|
+
proxies.delete(proxyKey(runId, scopeKey));
|
|
372
|
+
await opened.proxy.close().catch(() => undefined);
|
|
373
|
+
}
|
|
374
|
+
await docker.removeNetwork(opened.networkId).catch(() => undefined);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const takeSnapshot = async (lease) => {
|
|
381
|
+
const archived = await docker.getArchive(lease.containerId, workspaceRoot, limits.maxWorkspaceBytes * 2 + 65_536);
|
|
382
|
+
if (archived.stat.linkTarget) {
|
|
383
|
+
throw new SandboxError("SYMLINK_DENIED", "Refusing to snapshot a symlinked workspace root.");
|
|
384
|
+
}
|
|
385
|
+
if (archived.tar.byteLength > limits.maxWorkspaceBytes * 2 + 65_536) {
|
|
386
|
+
throw new SandboxError("LIMIT", "Workspace snapshot exceeds the configured size limit.");
|
|
387
|
+
}
|
|
388
|
+
const previous = await snapshots.load(lease.runId, lease.scopeKey);
|
|
389
|
+
let imageId;
|
|
390
|
+
if (snapshotMode === "docker-commit") {
|
|
391
|
+
const committed = await docker.commitContainer(lease.containerId, "zhivex-sandbox", `${lease.runId.replace(/[^a-zA-Z0-9_.-]/g, "").slice(0, 40) || "snapshot"}-${randomUUID().slice(0, 8)}`, {
|
|
392
|
+
"zhivex.sandbox": "1",
|
|
393
|
+
"zhivex.owner": ownerId,
|
|
394
|
+
"zhivex.fingerprint": binding.fingerprint,
|
|
395
|
+
"zhivex.run-id": lease.runId
|
|
396
|
+
});
|
|
397
|
+
imageId = committed.Id;
|
|
398
|
+
}
|
|
399
|
+
const carried = collectImageIds(previous?.pendingImageIds, previous?.imageId);
|
|
400
|
+
const pendingImageIds = carried.filter(id => id !== imageId);
|
|
401
|
+
const snapshot = withPendingImages({
|
|
402
|
+
schemaVersion: 1,
|
|
403
|
+
id: `snap_${randomUUID()}`,
|
|
404
|
+
runId: lease.runId,
|
|
405
|
+
scopeKey: lease.scopeKey,
|
|
406
|
+
mode: snapshotMode,
|
|
407
|
+
imageId,
|
|
408
|
+
workspaceTar: archived.tar,
|
|
409
|
+
createdAt: clock()
|
|
410
|
+
}, pendingImageIds);
|
|
411
|
+
try {
|
|
412
|
+
await snapshots.save(snapshot);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (imageId)
|
|
416
|
+
await retainUnsavedImage(previous, imageId);
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
if (!pendingImageIds.length)
|
|
420
|
+
return snapshot;
|
|
421
|
+
const { retained, failure } = await deleteSnapshotImages(pendingImageIds);
|
|
422
|
+
if (retained.length !== pendingImageIds.length) {
|
|
423
|
+
try {
|
|
424
|
+
await snapshots.save(withPendingImages(snapshot, retained));
|
|
425
|
+
}
|
|
426
|
+
catch (saveError) {
|
|
427
|
+
if (failure)
|
|
428
|
+
throw failure;
|
|
429
|
+
throw saveError;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (failure)
|
|
433
|
+
throw failure;
|
|
434
|
+
return withPendingImages(snapshot, retained);
|
|
435
|
+
};
|
|
436
|
+
const removeSnapshotImage = async (imageId) => {
|
|
437
|
+
await docker.removeImage(imageId).catch((error) => {
|
|
438
|
+
if (error instanceof SandboxError && error.code === "NOT_FOUND")
|
|
439
|
+
return;
|
|
440
|
+
throw error;
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
const deleteSnapshotImages = async (ids) => {
|
|
444
|
+
const retained = [];
|
|
445
|
+
let failure;
|
|
446
|
+
for (const id of ids) {
|
|
447
|
+
try {
|
|
448
|
+
await removeSnapshotImage(id);
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
retained.push(id);
|
|
452
|
+
failure ??= error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return { retained, failure };
|
|
456
|
+
};
|
|
457
|
+
const retainUnsavedImage = async (previous, imageId) => {
|
|
458
|
+
if (previous) {
|
|
459
|
+
const pending = collectImageIds(previous.pendingImageIds, imageId).filter(id => id !== previous.imageId);
|
|
460
|
+
try {
|
|
461
|
+
await snapshots.save(withPendingImages(previous, pending));
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// The replacement record was not stored. Delete the untracked image instead.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
await removeSnapshotImage(imageId).catch(() => undefined);
|
|
469
|
+
};
|
|
470
|
+
const loadSnapshot = async (runId, scopeKey) => snapshots.load(runId, scopeKey).catch((error) => {
|
|
471
|
+
if (error instanceof SandboxError && (error.code === "NOT_FOUND" || error.code === "STORE_ID"))
|
|
472
|
+
return undefined;
|
|
473
|
+
throw error;
|
|
474
|
+
});
|
|
475
|
+
const retryPendingSnapshotImages = async (runId, scopeKey) => {
|
|
476
|
+
const stored = await loadSnapshot(runId, scopeKey);
|
|
477
|
+
if (!stored?.pendingImageIds?.length)
|
|
478
|
+
return;
|
|
479
|
+
const pending = collectImageIds(stored.pendingImageIds).filter(id => id !== stored.imageId);
|
|
480
|
+
const { retained, failure } = await deleteSnapshotImages(pending);
|
|
481
|
+
if (retained.length !== pending.length) {
|
|
482
|
+
try {
|
|
483
|
+
await snapshots.save(withPendingImages(stored, retained));
|
|
484
|
+
}
|
|
485
|
+
catch (saveError) {
|
|
486
|
+
if (failure)
|
|
487
|
+
throw failure;
|
|
488
|
+
throw saveError;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (failure)
|
|
492
|
+
throw failure;
|
|
493
|
+
};
|
|
494
|
+
const discardSnapshot = async (runId, scopeKey) => {
|
|
495
|
+
const stored = await loadSnapshot(runId, scopeKey);
|
|
496
|
+
if (!stored)
|
|
497
|
+
return;
|
|
498
|
+
const ids = collectImageIds(stored.pendingImageIds, stored.imageId);
|
|
499
|
+
const { retained, failure } = await deleteSnapshotImages(ids);
|
|
500
|
+
if (retained.length > 0) {
|
|
501
|
+
const imageId = stored.imageId && retained.includes(stored.imageId) ? stored.imageId : undefined;
|
|
502
|
+
try {
|
|
503
|
+
await snapshots.save(withPendingImages({ ...stored, imageId }, retained.filter(id => id !== imageId)));
|
|
504
|
+
}
|
|
505
|
+
catch (saveError) {
|
|
506
|
+
if (failure)
|
|
507
|
+
throw failure;
|
|
508
|
+
throw saveError;
|
|
509
|
+
}
|
|
510
|
+
throw failure ?? new SandboxError("DOCKER_API", "Sandbox snapshot image could not be removed.");
|
|
511
|
+
}
|
|
512
|
+
await snapshots.remove(runId, scopeKey);
|
|
513
|
+
};
|
|
514
|
+
const openSession = (initial) => {
|
|
515
|
+
let lease = initial;
|
|
516
|
+
let closed = false;
|
|
517
|
+
let activeExecs = 0;
|
|
518
|
+
const waiters = [];
|
|
519
|
+
const assertOpen = () => {
|
|
520
|
+
if (closed || lease.status !== "active") {
|
|
521
|
+
throw new SandboxError("SANDBOX_CLOSED", "Sandbox session is closed.");
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
let revisionChain = Promise.resolve();
|
|
525
|
+
let pendingTouch;
|
|
526
|
+
const enqueueLease = (operation) => {
|
|
527
|
+
const run = revisionChain.then(operation);
|
|
528
|
+
revisionChain = run.then(() => undefined, () => undefined);
|
|
529
|
+
return run;
|
|
530
|
+
};
|
|
531
|
+
const touch = () => {
|
|
532
|
+
assertOpen();
|
|
533
|
+
if (lease.expiresAt <= clock())
|
|
534
|
+
throw new SandboxError("LEASE_EXPIRED", `Sandbox lease for run "${lease.runId}" has expired.`);
|
|
535
|
+
if (!pendingTouch) {
|
|
536
|
+
const run = enqueueLease(async () => {
|
|
537
|
+
if (lease.status !== "active")
|
|
538
|
+
return;
|
|
539
|
+
if (lease.expiresAt <= clock())
|
|
540
|
+
throw new SandboxError("LEASE_EXPIRED", `Sandbox lease for run "${lease.runId}" has expired.`);
|
|
541
|
+
const next = { ...lease, expiresAt: clock() + ttl, revision: lease.revision + 1 };
|
|
542
|
+
lease = await leases.compareAndSwap(next, lease.revision);
|
|
543
|
+
});
|
|
544
|
+
pendingTouch = run;
|
|
545
|
+
void run.then(() => {
|
|
546
|
+
if (pendingTouch === run)
|
|
547
|
+
pendingTouch = undefined;
|
|
548
|
+
}, () => {
|
|
549
|
+
if (pendingTouch === run)
|
|
550
|
+
pendingTouch = undefined;
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return pendingTouch;
|
|
554
|
+
};
|
|
555
|
+
const enter = async () => {
|
|
556
|
+
if (activeExecs >= limits.maxConcurrentProcesses) {
|
|
557
|
+
await new Promise(resolve => waiters.push(resolve));
|
|
558
|
+
}
|
|
559
|
+
activeExecs += 1;
|
|
560
|
+
};
|
|
561
|
+
const leave = () => {
|
|
562
|
+
activeExecs -= 1;
|
|
563
|
+
waiters.shift()?.();
|
|
564
|
+
};
|
|
565
|
+
const containerExec = async (cmd, timeoutMs, signal, marker, maxOutputBytes = limits.maxProcessOutputBytes) => {
|
|
566
|
+
await enter();
|
|
567
|
+
try {
|
|
568
|
+
return await docker.exec(lease.containerId, {
|
|
569
|
+
cmd,
|
|
570
|
+
user: user.user,
|
|
571
|
+
env: marker ? [`ZHIVEX_EXEC_ID=${marker}`, ...baseEnv()] : baseEnv(),
|
|
572
|
+
workingDir: workspaceRoot,
|
|
573
|
+
timeoutMs,
|
|
574
|
+
maxOutputBytes,
|
|
575
|
+
signal,
|
|
576
|
+
marker
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
leave();
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
const session = {
|
|
584
|
+
kind: "docker-sandbox",
|
|
585
|
+
manifest,
|
|
586
|
+
binding,
|
|
587
|
+
get containerId() {
|
|
588
|
+
return lease.containerId;
|
|
589
|
+
},
|
|
590
|
+
authorize(request) {
|
|
591
|
+
try {
|
|
592
|
+
authorizeTool(request, shell, access, undeclaredTools, limits.maxFileBytes);
|
|
593
|
+
return { decision: "allow" };
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
return { decision: "deny", reason: error instanceof Error ? error.message : String(error) };
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
async execute(_request, operation) {
|
|
600
|
+
assertOpen();
|
|
601
|
+
return raceDeadline(Promise.resolve().then(() => operation()), limits.maxProcessRuntimeMs, "Sandbox tool callback exceeded its deadline.");
|
|
602
|
+
},
|
|
603
|
+
async readFile(filePath) {
|
|
604
|
+
assertOpen();
|
|
605
|
+
await touch();
|
|
606
|
+
const absolute = resolveWorkspacePath(workspaceRoot, filePath);
|
|
607
|
+
await assertResolvedWorkspacePath(docker, lease.containerId, workspaceRoot, absolute);
|
|
608
|
+
const archived = await docker.getArchive(lease.containerId, absolute, limits.maxFileBytes + 8_192);
|
|
609
|
+
if (archived.stat.linkTarget) {
|
|
610
|
+
throw new SandboxError("SYMLINK_DENIED", "Sandbox file reads do not follow symlinks.");
|
|
611
|
+
}
|
|
612
|
+
const file = unpackTar(archived.tar).find(entry => entry.type === "file");
|
|
613
|
+
if (!file?.content)
|
|
614
|
+
throw new SandboxError("NOT_FOUND", `Sandbox file ${filePath} was not found.`);
|
|
615
|
+
if (file.content.byteLength > limits.maxFileBytes) {
|
|
616
|
+
throw new SandboxError("LIMIT", "Sandbox file exceeds maxFileBytes.");
|
|
617
|
+
}
|
|
618
|
+
return file.content;
|
|
619
|
+
},
|
|
620
|
+
async writeFile(filePath, contents) {
|
|
621
|
+
assertOpen();
|
|
622
|
+
if (access !== "read-write")
|
|
623
|
+
throw new SandboxError("READ_ONLY", "Sandbox workspace is read-only.");
|
|
624
|
+
await touch();
|
|
625
|
+
const absolute = resolveWorkspacePath(workspaceRoot, filePath);
|
|
626
|
+
await assertResolvedWorkspacePath(docker, lease.containerId, workspaceRoot, absolute);
|
|
627
|
+
const bytes = await boundedBytes(typeof contents === "string" ? encoder.encode(contents) : contents, limits.maxFileBytes, filePath);
|
|
628
|
+
const relative = absolute.slice(workspaceRoot.length + 1);
|
|
629
|
+
await docker.putArchive(lease.containerId, workspaceRoot, packTar(withParents([{
|
|
630
|
+
path: relative,
|
|
631
|
+
type: "file",
|
|
632
|
+
content: bytes,
|
|
633
|
+
mode: 0o644,
|
|
634
|
+
uid: user.uid,
|
|
635
|
+
gid: user.gid
|
|
636
|
+
}], user.uid, user.gid)));
|
|
637
|
+
},
|
|
638
|
+
async list(filePath = ".") {
|
|
639
|
+
assertOpen();
|
|
640
|
+
await touch();
|
|
641
|
+
const absolute = resolveWorkspacePath(workspaceRoot, filePath);
|
|
642
|
+
await assertResolvedWorkspacePath(docker, lease.containerId, workspaceRoot, absolute);
|
|
643
|
+
const listCap = Math.max(limits.maxProcessOutputBytes, 65_536);
|
|
644
|
+
const result = await containerExec(["/bin/ls", "-1A", absolute], limits.maxProcessRuntimeMs, undefined, undefined, listCap);
|
|
645
|
+
if (result.truncated) {
|
|
646
|
+
throw new SandboxError("LIMIT", "Sandbox directory listing exceeds the output cap.");
|
|
647
|
+
}
|
|
648
|
+
if (result.exitCode !== 0) {
|
|
649
|
+
throw new SandboxError("NOT_FOUND", decodeCappedText(result.stderr, 500).text || `Sandbox path ${filePath} was not found.`);
|
|
650
|
+
}
|
|
651
|
+
return decodeCappedText(result.stdout, listCap).text.split("\n").filter(Boolean);
|
|
652
|
+
},
|
|
653
|
+
async exec(argv, execOptions) {
|
|
654
|
+
assertOpen();
|
|
655
|
+
const command = validateArgv([...argv], shell);
|
|
656
|
+
const timeoutMs = Math.min(execOptions?.timeoutMs ?? limits.maxProcessRuntimeMs, limits.maxProcessRuntimeMs);
|
|
657
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
658
|
+
throw new SandboxError("LIMIT", "Command timeout must be a positive integer millisecond value.");
|
|
659
|
+
}
|
|
660
|
+
await touch();
|
|
661
|
+
const marker = `zhivex_${randomUUID()}`;
|
|
662
|
+
const seconds = String(Math.max(1, Math.ceil(timeoutMs / 1000)));
|
|
663
|
+
const groupScript = 'sec="$1"; shift; setsid /usr/bin/timeout -s KILL "$sec" "$@" & leader=$!; wait "$leader"; status=$?; kill -KILL -"$leader" 2>/dev/null; exit "$status"';
|
|
664
|
+
const result = await containerExec(["/bin/sh", "-c", groupScript, marker, seconds, ...command], timeoutMs, execOptions?.signal, marker);
|
|
665
|
+
const stdout = decodeCappedText(result.stdout, limits.maxProcessOutputBytes);
|
|
666
|
+
const stderr = decodeCappedText(result.stderr, limits.maxProcessOutputBytes);
|
|
667
|
+
return {
|
|
668
|
+
exitCode: result.exitCode,
|
|
669
|
+
stdout: stdout.text,
|
|
670
|
+
stderr: stderr.text,
|
|
671
|
+
truncated: result.truncated || stdout.truncated || stderr.truncated,
|
|
672
|
+
timedOut: result.timedOut
|
|
673
|
+
};
|
|
674
|
+
},
|
|
675
|
+
async snapshot() {
|
|
676
|
+
assertOpen();
|
|
677
|
+
const snapshot = await takeSnapshot(lease);
|
|
678
|
+
await enqueueLease(async () => {
|
|
679
|
+
const next = { ...lease, snapshotId: snapshot.id, revision: lease.revision + 1, expiresAt: clock() + ttl };
|
|
680
|
+
lease = await leases.compareAndSwap(next, lease.revision);
|
|
681
|
+
});
|
|
682
|
+
return { id: snapshot.id, mode: snapshot.mode, imageId: snapshot.imageId };
|
|
683
|
+
},
|
|
684
|
+
async release(result) {
|
|
685
|
+
if (closed)
|
|
686
|
+
return;
|
|
687
|
+
closed = true;
|
|
688
|
+
if (!terminalStatus.has(result.status)) {
|
|
689
|
+
const snapshot = await takeSnapshot(lease);
|
|
690
|
+
await enqueueLease(async () => {
|
|
691
|
+
const next = { ...lease, snapshotId: snapshot.id, revision: lease.revision + 1, expiresAt: clock() + ttl };
|
|
692
|
+
lease = await leases.compareAndSwap(next, lease.revision);
|
|
693
|
+
});
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
await enqueueLease(async () => {
|
|
697
|
+
const next = { ...lease, status: "released", revision: lease.revision + 1, expiresAt: clock() };
|
|
698
|
+
lease = await leases.compareAndSwap(next, lease.revision);
|
|
699
|
+
});
|
|
700
|
+
try {
|
|
701
|
+
await docker.removeContainer(lease.containerId).catch((error) => {
|
|
702
|
+
if (!(error instanceof SandboxError) || error.code !== "NOT_FOUND")
|
|
703
|
+
throw error;
|
|
704
|
+
});
|
|
705
|
+
await discardSnapshot(lease.runId, lease.scopeKey);
|
|
706
|
+
}
|
|
707
|
+
finally {
|
|
708
|
+
if (lease.networkId)
|
|
709
|
+
await docker.removeNetwork(lease.networkId).catch(() => undefined);
|
|
710
|
+
const proxy = proxies.get(proxyKey(lease.runId, lease.scopeKey));
|
|
711
|
+
proxies.delete(proxyKey(lease.runId, lease.scopeKey));
|
|
712
|
+
await proxy?.proxy.close().catch(() => undefined);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
return session;
|
|
717
|
+
};
|
|
718
|
+
return {
|
|
719
|
+
manifest,
|
|
720
|
+
async acquire(request) {
|
|
721
|
+
if (request.abortSignal?.aborted)
|
|
722
|
+
throw new SandboxError("CANCELLED", "Sandbox acquire was cancelled.");
|
|
723
|
+
const scopeKey = scopeKeyOf(request.scope);
|
|
724
|
+
const existing = await leases.load(request.runId, scopeKey);
|
|
725
|
+
if (existing && (existing.fingerprint !== binding.fingerprint ||
|
|
726
|
+
existing.environmentId !== binding.environmentId ||
|
|
727
|
+
existing.workspaceId !== binding.workspaceId)) {
|
|
728
|
+
throw new ConflictError(`Execution environment binding changed for agent run "${request.runId}".`);
|
|
729
|
+
}
|
|
730
|
+
if (existing?.status === "released") {
|
|
731
|
+
throw new SandboxError("SANDBOX_RELEASED", `Sandbox for run "${request.runId}" was already released.`);
|
|
732
|
+
}
|
|
733
|
+
if (existing && (existing.status === "expired" || existing.expiresAt <= clock())) {
|
|
734
|
+
if (existing.status === "active") {
|
|
735
|
+
await docker.removeContainer(existing.containerId).catch(() => undefined);
|
|
736
|
+
if (existing.networkId)
|
|
737
|
+
await docker.removeNetwork(existing.networkId).catch(() => undefined);
|
|
738
|
+
const proxy = proxies.get(proxyKey(request.runId, scopeKey));
|
|
739
|
+
proxies.delete(proxyKey(request.runId, scopeKey));
|
|
740
|
+
await proxy?.proxy.close().catch(() => undefined);
|
|
741
|
+
const expired = { ...existing, status: "expired", revision: existing.revision + 1 };
|
|
742
|
+
await leases.compareAndSwap(expired, existing.revision).catch(() => undefined);
|
|
743
|
+
}
|
|
744
|
+
await discardSnapshot(request.runId, scopeKey);
|
|
745
|
+
throw new SandboxError("LEASE_EXPIRED", `Sandbox lease for run "${request.runId}" has expired.`);
|
|
746
|
+
}
|
|
747
|
+
if (!existing) {
|
|
748
|
+
const lease = await bootContainer(request.runId, scopeKey, undefined, undefined, undefined, request.abortSignal);
|
|
749
|
+
return openSession(lease);
|
|
750
|
+
}
|
|
751
|
+
let running = false;
|
|
752
|
+
let inspection;
|
|
753
|
+
try {
|
|
754
|
+
inspection = await docker.inspectContainer(existing.containerId);
|
|
755
|
+
running = inspection.State.Running;
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
if (!(error instanceof SandboxError) || error.code !== "NOT_FOUND")
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
const proxyAlive = !allowlist || proxies.has(proxyKey(request.runId, scopeKey));
|
|
762
|
+
if (running && proxyAlive) {
|
|
763
|
+
const host = inspection?.HostConfig;
|
|
764
|
+
if (host && (host.NanoCpus !== limits.nanoCpus || host.PidsLimit !== limits.pidsLimit || host.Memory !== limits.memoryBytes)) {
|
|
765
|
+
throw new ConflictError(`Execution environment binding changed for agent run "${request.runId}".`);
|
|
766
|
+
}
|
|
767
|
+
const renewed = { ...existing, expiresAt: clock() + ttl, revision: existing.revision + 1 };
|
|
768
|
+
await leases.compareAndSwap(renewed, existing.revision);
|
|
769
|
+
return openSession(renewed);
|
|
770
|
+
}
|
|
771
|
+
let workspaceTar;
|
|
772
|
+
let imageId;
|
|
773
|
+
if (running) {
|
|
774
|
+
workspaceTar = (await docker.getArchive(existing.containerId, workspaceRoot, limits.maxWorkspaceBytes * 2 + 65_536)).tar;
|
|
775
|
+
await docker.removeContainer(existing.containerId).catch(() => undefined);
|
|
776
|
+
}
|
|
777
|
+
const stored = await snapshots.load(request.runId, scopeKey);
|
|
778
|
+
workspaceTar ??= stored?.workspaceTar;
|
|
779
|
+
imageId = stored?.imageId;
|
|
780
|
+
if (!workspaceTar && !imageId) {
|
|
781
|
+
throw new SandboxError("SANDBOX_LOST", `Sandbox container for run "${request.runId}" is gone and no snapshot is available.`);
|
|
782
|
+
}
|
|
783
|
+
await docker.removeContainer(existing.containerId).catch(() => undefined);
|
|
784
|
+
if (!proxyAlive && existing.networkId) {
|
|
785
|
+
await docker.removeNetwork(existing.networkId).catch(() => undefined);
|
|
786
|
+
}
|
|
787
|
+
const lease = await bootContainer(request.runId, scopeKey, existing, workspaceTar, imageId, request.abortSignal);
|
|
788
|
+
return openSession(lease);
|
|
789
|
+
},
|
|
790
|
+
async sweep(now = clock()) {
|
|
791
|
+
const removed = [];
|
|
792
|
+
for (const lease of await leases.list()) {
|
|
793
|
+
if (lease.environmentId !== manifest.id || lease.fingerprint !== binding.fingerprint)
|
|
794
|
+
continue;
|
|
795
|
+
if (lease.status === "active" && lease.expiresAt > now) {
|
|
796
|
+
await retryPendingSnapshotImages(lease.runId, lease.scopeKey);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
await discardSnapshot(lease.runId, lease.scopeKey);
|
|
800
|
+
if (lease.status !== "active")
|
|
801
|
+
continue;
|
|
802
|
+
await docker.removeContainer(lease.containerId).catch(() => undefined);
|
|
803
|
+
if (lease.networkId)
|
|
804
|
+
await docker.removeNetwork(lease.networkId).catch(() => undefined);
|
|
805
|
+
const proxy = proxies.get(proxyKey(lease.runId, lease.scopeKey));
|
|
806
|
+
proxies.delete(proxyKey(lease.runId, lease.scopeKey));
|
|
807
|
+
await proxy?.proxy.close();
|
|
808
|
+
await leases.compareAndSwap({ ...lease, status: "expired", revision: lease.revision + 1 }, lease.revision).catch(() => undefined);
|
|
809
|
+
removed.push(lease.containerId);
|
|
810
|
+
}
|
|
811
|
+
const active = new Set((await leases.list()).filter(lease => lease.status === "active").map(lease => lease.containerId));
|
|
812
|
+
for (const container of await docker.listContainers("zhivex.sandbox=1")) {
|
|
813
|
+
const labels = container.Labels;
|
|
814
|
+
if (labels["zhivex.environment-id"] !== manifest.id)
|
|
815
|
+
continue;
|
|
816
|
+
if (labels["zhivex.owner"] !== ownerId || labels["zhivex.fingerprint"] !== binding.fingerprint)
|
|
817
|
+
continue;
|
|
818
|
+
if (active.has(container.Id))
|
|
819
|
+
continue;
|
|
820
|
+
await docker.removeContainer(container.Id).catch(() => undefined);
|
|
821
|
+
removed.push(container.Id);
|
|
822
|
+
}
|
|
823
|
+
return { removedContainerIds: removed };
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
};
|
|
827
|
+
const IMAGE_ID = /^[A-Za-z0-9:_./@+-]{1,200}$/;
|
|
828
|
+
const collectImageIds = (...groups) => {
|
|
829
|
+
const seen = new Set();
|
|
830
|
+
const ids = [];
|
|
831
|
+
for (const group of groups) {
|
|
832
|
+
if (group === undefined)
|
|
833
|
+
continue;
|
|
834
|
+
const values = typeof group === "string" ? [group] : group;
|
|
835
|
+
if (!Array.isArray(values)) {
|
|
836
|
+
throw new SandboxError("SNAPSHOT_INVALID", "Sandbox snapshot image ids are not usable.");
|
|
837
|
+
}
|
|
838
|
+
for (const id of values) {
|
|
839
|
+
if (typeof id !== "string" || !IMAGE_ID.test(id)) {
|
|
840
|
+
throw new SandboxError("SNAPSHOT_INVALID", "Sandbox snapshot image id is not usable.");
|
|
841
|
+
}
|
|
842
|
+
if (seen.has(id))
|
|
843
|
+
continue;
|
|
844
|
+
seen.add(id);
|
|
845
|
+
ids.push(id);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (ids.length > 32) {
|
|
849
|
+
throw new SandboxError("SNAPSHOT_INVALID", "Sandbox snapshot has too many images waiting for deletion.");
|
|
850
|
+
}
|
|
851
|
+
return ids;
|
|
852
|
+
};
|
|
853
|
+
const withPendingImages = (snapshot, pending) => {
|
|
854
|
+
const next = { ...snapshot };
|
|
855
|
+
if (pending.length)
|
|
856
|
+
next.pendingImageIds = [...pending];
|
|
857
|
+
else
|
|
858
|
+
delete next.pendingImageIds;
|
|
859
|
+
if (!next.imageId)
|
|
860
|
+
delete next.imageId;
|
|
861
|
+
return next;
|
|
862
|
+
};
|
|
863
|
+
const withParents = (entries, uid, gid) => {
|
|
864
|
+
const directories = new Set(entries.filter(entry => entry.type === "directory").map(entry => entry.path));
|
|
865
|
+
const parents = [];
|
|
866
|
+
for (const entry of entries) {
|
|
867
|
+
const parts = entry.path.split("/");
|
|
868
|
+
let prefix = "";
|
|
869
|
+
for (const part of parts.slice(0, -1)) {
|
|
870
|
+
prefix = prefix ? `${prefix}/${part}` : part;
|
|
871
|
+
if (directories.has(prefix))
|
|
872
|
+
continue;
|
|
873
|
+
directories.add(prefix);
|
|
874
|
+
parents.push({ path: prefix, type: "directory", mode: 0o755, uid, gid });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return [...parents, ...entries];
|
|
878
|
+
};
|
|
879
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
880
|
+
const clampTimeout = (timeoutMs) => {
|
|
881
|
+
if (!Number.isFinite(timeoutMs))
|
|
882
|
+
return 1;
|
|
883
|
+
return Math.min(MAX_TIMER_DELAY_MS, Math.max(1, Math.floor(timeoutMs)));
|
|
884
|
+
};
|
|
885
|
+
const raceDeadline = (operation, timeoutMs, message) => new Promise((resolve, reject) => {
|
|
886
|
+
let settled = false;
|
|
887
|
+
const timer = setTimeout(() => {
|
|
888
|
+
if (settled)
|
|
889
|
+
return;
|
|
890
|
+
settled = true;
|
|
891
|
+
reject(new SandboxError("LIMIT", message));
|
|
892
|
+
}, clampTimeout(timeoutMs));
|
|
893
|
+
operation.then(value => {
|
|
894
|
+
if (settled)
|
|
895
|
+
return;
|
|
896
|
+
settled = true;
|
|
897
|
+
clearTimeout(timer);
|
|
898
|
+
resolve(value);
|
|
899
|
+
}, error => {
|
|
900
|
+
if (settled)
|
|
901
|
+
return;
|
|
902
|
+
settled = true;
|
|
903
|
+
clearTimeout(timer);
|
|
904
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
const boundedBytes = async (bytes, max, label) => {
|
|
908
|
+
if (bytes.byteLength > max)
|
|
909
|
+
throw new SandboxError("LIMIT", `Sandbox file ${label} exceeds maxFileBytes.`);
|
|
910
|
+
return bytes;
|
|
911
|
+
};
|
|
912
|
+
const throwIfSourceAborted = (signal) => {
|
|
913
|
+
if (signal?.aborted)
|
|
914
|
+
throw new SandboxError("CANCELLED", "Sandbox source import was cancelled.");
|
|
915
|
+
};
|
|
916
|
+
const assertResolvedWorkspacePath = async (docker, containerId, workspaceRoot, absolute) => {
|
|
917
|
+
const suffix = [];
|
|
918
|
+
let probe = absolute;
|
|
919
|
+
while (true) {
|
|
920
|
+
try {
|
|
921
|
+
const canonical = await docker.realpath(containerId, probe);
|
|
922
|
+
const rebuilt = suffix.length === 0 ? canonical : path.posix.join(canonical, ...suffix);
|
|
923
|
+
if (rebuilt !== absolute || (rebuilt !== workspaceRoot && !rebuilt.startsWith(`${workspaceRoot}/`))) {
|
|
924
|
+
throw new SandboxError("SYMLINK_DENIED", "Sandbox paths do not follow symlinks.");
|
|
925
|
+
}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
catch (error) {
|
|
929
|
+
if (!(error instanceof SandboxError) || error.code !== "NOT_FOUND")
|
|
930
|
+
throw error;
|
|
931
|
+
if (probe === workspaceRoot || probe === "/") {
|
|
932
|
+
throw new SandboxError("NOT_FOUND", `Sandbox path ${absolute} was not found.`);
|
|
933
|
+
}
|
|
934
|
+
const parent = path.posix.dirname(probe);
|
|
935
|
+
if (parent === probe)
|
|
936
|
+
throw new SandboxError("NOT_FOUND", `Sandbox path ${absolute} was not found.`);
|
|
937
|
+
suffix.unshift(path.posix.basename(probe));
|
|
938
|
+
probe = parent;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
const readSourceTree = async (root, include, limits, uid, gid, budget, signal) => {
|
|
943
|
+
sourceBytesRead = 0;
|
|
944
|
+
const realRoot = path.resolve(root);
|
|
945
|
+
const rootStat = await lstat(realRoot);
|
|
946
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
947
|
+
throw new SandboxError("WORKSPACE_SOURCE", "Sandbox source root must be a directory.");
|
|
948
|
+
}
|
|
949
|
+
const entries = [];
|
|
950
|
+
for (const relative of include) {
|
|
951
|
+
throwIfSourceAborted(signal);
|
|
952
|
+
const safe = assertRelativeWorkspacePath(relative);
|
|
953
|
+
const absolute = path.resolve(realRoot, safe);
|
|
954
|
+
if (absolute !== realRoot && !absolute.startsWith(`${realRoot}${path.sep}`)) {
|
|
955
|
+
throw new SandboxError("PATH_ESCAPE", `Source path ${relative} escapes the source root.`);
|
|
956
|
+
}
|
|
957
|
+
await copySource(absolute, safe, entries, limits, uid, gid, 0, budget, signal);
|
|
958
|
+
}
|
|
959
|
+
return entries;
|
|
960
|
+
};
|
|
961
|
+
const copySource = async (absolute, relative, entries, limits, uid, gid, depth, budget, signal) => {
|
|
962
|
+
throwIfSourceAborted(signal);
|
|
963
|
+
if (depth > 32 || entries.length > 1000) {
|
|
964
|
+
throw new SandboxError("LIMIT", "Sandbox source copy exceeds the file or depth limit.");
|
|
965
|
+
}
|
|
966
|
+
const info = await lstat(absolute);
|
|
967
|
+
if (info.isSymbolicLink())
|
|
968
|
+
throw new SandboxError("SYMLINK_DENIED", `Refusing to copy symlink ${relative}.`);
|
|
969
|
+
if (info.isDirectory()) {
|
|
970
|
+
entries.push({ path: relative, type: "directory", mode: 0o755, uid, gid });
|
|
971
|
+
for (const name of (await readdir(absolute)).sort()) {
|
|
972
|
+
await copySource(path.join(absolute, name), `${relative}/${name}`, entries, limits, uid, gid, depth + 1, budget, signal);
|
|
973
|
+
}
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (!info.isFile())
|
|
977
|
+
throw new SandboxError("WORKSPACE_SOURCE", `Source path ${relative} is not a regular file.`);
|
|
978
|
+
if (info.size > limits.maxFileBytes) {
|
|
979
|
+
throw new SandboxError("LIMIT", `Source file ${relative} exceeds maxFileBytes.`);
|
|
980
|
+
}
|
|
981
|
+
if (info.size > budget.remaining) {
|
|
982
|
+
throw new SandboxError("LIMIT", `Source file ${relative} exceeds maxWorkspaceBytes.`);
|
|
983
|
+
}
|
|
984
|
+
const content = await readBoundedFile(absolute, relative, limits.maxFileBytes, budget.remaining);
|
|
985
|
+
budget.remaining -= content.byteLength;
|
|
986
|
+
entries.push({ path: relative, type: "file", content, mode: 0o644, uid, gid });
|
|
987
|
+
};
|
|
988
|
+
const readBoundedFile = async (absolute, relative, maxFileBytes, remaining) => {
|
|
989
|
+
const cap = Math.min(maxFileBytes, remaining);
|
|
990
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
991
|
+
try {
|
|
992
|
+
const info = await handle.stat();
|
|
993
|
+
if (!info.isFile())
|
|
994
|
+
throw new SandboxError("WORKSPACE_SOURCE", `Source path ${relative} is not a regular file.`);
|
|
995
|
+
if (info.size > maxFileBytes)
|
|
996
|
+
throw new SandboxError("LIMIT", `Source file ${relative} exceeds maxFileBytes.`);
|
|
997
|
+
if (info.size > remaining)
|
|
998
|
+
throw new SandboxError("LIMIT", `Source file ${relative} exceeds maxWorkspaceBytes.`);
|
|
999
|
+
const target = new Uint8Array(info.size);
|
|
1000
|
+
let offset = 0;
|
|
1001
|
+
while (offset < info.size) {
|
|
1002
|
+
const chunk = await handle.read(target, offset, Math.min(65_536, info.size - offset), offset);
|
|
1003
|
+
if (chunk.bytesRead <= 0)
|
|
1004
|
+
break;
|
|
1005
|
+
offset += chunk.bytesRead;
|
|
1006
|
+
sourceBytesRead += chunk.bytesRead;
|
|
1007
|
+
if (offset > cap)
|
|
1008
|
+
throw new SandboxError("LIMIT", `Source file ${relative} exceeds maxFileBytes.`);
|
|
1009
|
+
}
|
|
1010
|
+
if (offset !== info.size) {
|
|
1011
|
+
throw new SandboxError("WORKSPACE_SOURCE", `Source file ${relative} changed while it was read.`);
|
|
1012
|
+
}
|
|
1013
|
+
return target;
|
|
1014
|
+
}
|
|
1015
|
+
catch (error) {
|
|
1016
|
+
if (error.code === "ELOOP") {
|
|
1017
|
+
throw new SandboxError("SYMLINK_DENIED", `Refusing to copy symlink ${relative}.`);
|
|
1018
|
+
}
|
|
1019
|
+
throw error;
|
|
1020
|
+
}
|
|
1021
|
+
finally {
|
|
1022
|
+
await handle.close();
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
const authorizeTool = (request, shell, access, undeclaredTools, maxFileBytes) => {
|
|
1026
|
+
const input = request.input && typeof request.input === "object" ? request.input : undefined;
|
|
1027
|
+
if (request.tool.name === "sandbox_read_file" || request.tool.name === "sandbox_list") {
|
|
1028
|
+
if (typeof input?.path === "string")
|
|
1029
|
+
resolveWorkspacePath("/workspace", input.path);
|
|
1030
|
+
else if (request.tool.name === "sandbox_read_file")
|
|
1031
|
+
throw new SandboxError("PATH_INVALID", "sandbox_read_file requires a path.");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (request.tool.name === "sandbox_write_file") {
|
|
1035
|
+
if (access !== "read-write")
|
|
1036
|
+
throw new SandboxError("READ_ONLY", "Sandbox workspace is read-only.");
|
|
1037
|
+
if (typeof input?.path !== "string")
|
|
1038
|
+
throw new SandboxError("PATH_INVALID", "sandbox_write_file requires a path.");
|
|
1039
|
+
resolveWorkspacePath("/workspace", input.path);
|
|
1040
|
+
const contents = typeof input.contents === "string" ? encoder.encode(input.contents) : undefined;
|
|
1041
|
+
if (!contents)
|
|
1042
|
+
throw new SandboxError("PATH_INVALID", "sandbox_write_file requires string contents.");
|
|
1043
|
+
if (contents.byteLength > maxFileBytes)
|
|
1044
|
+
throw new SandboxError("LIMIT", "Sandbox write exceeds maxFileBytes.");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (request.tool.name === "sandbox_exec") {
|
|
1048
|
+
validateArgv(input?.argv, shell);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
if (undeclaredTools === "deny") {
|
|
1052
|
+
throw new SandboxError("TOOL_DENIED", `Tool "${request.tool.name}" is not declared for this sandbox.`);
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
export const isDockerSandboxSession = (value) => Boolean(value) && typeof value === "object" && value.kind === "docker-sandbox";
|
|
1056
|
+
//# sourceMappingURL=environment.js.map
|