@evelandhq/sandbox-bwrap 0.1.2 → 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 +102 -45
- package/dist/backend.d.ts +5 -2
- package/dist/backend.js +80 -28
- package/dist/cache.d.ts +75 -0
- package/dist/cache.js +283 -0
- package/dist/events.d.ts +44 -0
- package/dist/events.js +1 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +2 -1
- package/dist/options.d.ts +19 -0
- package/dist/options.js +18 -0
- package/dist/process.js +30 -6
- package/dist/session.d.ts +5 -0
- package/dist/session.js +199 -38
- package/package.json +1 -1
package/dist/session.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { dirname } from "node:path";
|
|
4
5
|
import { Readable } from "node:stream";
|
|
@@ -11,14 +12,25 @@ function isMissingFileError(error) {
|
|
|
11
12
|
error !== null &&
|
|
12
13
|
error.code === "ENOENT");
|
|
13
14
|
}
|
|
14
|
-
async function collectStream(stream) {
|
|
15
|
+
async function collectStream(stream, budget, abort) {
|
|
15
16
|
const chunks = [];
|
|
16
17
|
const reader = stream.getReader();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
try {
|
|
19
|
+
for (;;) {
|
|
20
|
+
const { done, value } = await reader.read();
|
|
21
|
+
if (done)
|
|
22
|
+
break;
|
|
23
|
+
budget.used += value.byteLength;
|
|
24
|
+
if (budget.limit !== null && budget.used > budget.limit) {
|
|
25
|
+
const error = new Error(`bwrap sandbox: run output exceeded ${budget.limit} bytes`);
|
|
26
|
+
abort(error);
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
chunks.push(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
reader.releaseLock();
|
|
22
34
|
}
|
|
23
35
|
return Buffer.concat(chunks).toString("utf8");
|
|
24
36
|
}
|
|
@@ -34,22 +46,46 @@ function sliceLines(text, startLine, endLine) {
|
|
|
34
46
|
const lines = text.split("\n");
|
|
35
47
|
return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join("\n");
|
|
36
48
|
}
|
|
37
|
-
function createRunAbortSignal(callerSignal, timeoutMs) {
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
function createRunAbortSignal(callerSignal, timeoutMs, outputSignal) {
|
|
50
|
+
const signals = [outputSignal, ...(callerSignal ? [callerSignal] : [])];
|
|
51
|
+
if (timeoutMs === null) {
|
|
52
|
+
return { signal: AbortSignal.any(signals), clear() { } };
|
|
53
|
+
}
|
|
40
54
|
const timeout = new AbortController();
|
|
41
55
|
const timer = setTimeout(() => {
|
|
42
56
|
timeout.abort(new Error(`bwrap sandbox: run timed out after ${timeoutMs} ms`));
|
|
43
57
|
}, timeoutMs);
|
|
44
58
|
timer.unref?.();
|
|
45
59
|
return {
|
|
46
|
-
signal:
|
|
60
|
+
signal: AbortSignal.any([...signals, timeout.signal]),
|
|
47
61
|
clear: () => clearTimeout(timer),
|
|
48
62
|
};
|
|
49
63
|
}
|
|
50
64
|
export function createBwrapSession(input) {
|
|
51
65
|
const { id, workspaceDir, appRoot, runner, options } = input;
|
|
66
|
+
const generationId = input.generationId ?? randomUUID();
|
|
67
|
+
const tags = input.tags ?? {};
|
|
52
68
|
let networkPolicy = options.networkPolicy;
|
|
69
|
+
function emit(event) {
|
|
70
|
+
try {
|
|
71
|
+
const receipt = options.onEvent?.({
|
|
72
|
+
...event,
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
sessionId: id,
|
|
75
|
+
generationId,
|
|
76
|
+
tags,
|
|
77
|
+
});
|
|
78
|
+
if (receipt !== null &&
|
|
79
|
+
typeof receipt === "object" &&
|
|
80
|
+
"then" in receipt &&
|
|
81
|
+
typeof receipt.then === "function") {
|
|
82
|
+
void Promise.resolve(receipt).catch(() => { });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Observability is best effort and must never change sandbox behavior.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
53
89
|
const host = (path) => toHostPath(path, workspaceDir);
|
|
54
90
|
function writableHostPath(path, operation) {
|
|
55
91
|
const hostPath = host(path);
|
|
@@ -59,40 +95,109 @@ export function createBwrapSession(input) {
|
|
|
59
95
|
return hostPath;
|
|
60
96
|
}
|
|
61
97
|
const live = new Set();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
98
|
+
let lifecycle = "running";
|
|
99
|
+
let cleanupPromise;
|
|
100
|
+
let stoppedNotified = false;
|
|
101
|
+
emit({ type: "generation.started" });
|
|
102
|
+
function countedStream(stream, onBytes) {
|
|
103
|
+
const reader = stream.getReader();
|
|
104
|
+
return new ReadableStream({
|
|
105
|
+
async pull(controller) {
|
|
106
|
+
const { done, value } = await reader.read();
|
|
107
|
+
if (done) {
|
|
108
|
+
controller.close();
|
|
109
|
+
reader.releaseLock();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
onBytes(value.byteLength);
|
|
113
|
+
controller.enqueue(value);
|
|
114
|
+
},
|
|
115
|
+
async cancel(reason) {
|
|
116
|
+
await reader.cancel(reason);
|
|
117
|
+
reader.releaseLock();
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function finishReason(error, terminationReason) {
|
|
122
|
+
if (terminationReason)
|
|
123
|
+
return terminationReason;
|
|
124
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
125
|
+
if (message.includes("timed out"))
|
|
126
|
+
return "timeout";
|
|
127
|
+
if (message.includes("output exceeded"))
|
|
128
|
+
return "output-limit";
|
|
129
|
+
return error === undefined ? "exit" : "abort";
|
|
130
|
+
}
|
|
70
131
|
function track(proc) {
|
|
132
|
+
const commandId = randomUUID();
|
|
133
|
+
const startedAt = Date.now();
|
|
134
|
+
let stdoutBytes = 0;
|
|
135
|
+
let stderrBytes = 0;
|
|
136
|
+
let terminationReason;
|
|
137
|
+
const exit = proc.wait();
|
|
71
138
|
const wrapped = {
|
|
72
139
|
pid: proc.pid,
|
|
73
|
-
stdout: proc.stdout,
|
|
74
|
-
stderr: proc.stderr,
|
|
140
|
+
stdout: countedStream(proc.stdout, (count) => (stdoutBytes += count)),
|
|
141
|
+
stderr: countedStream(proc.stderr, (count) => (stderrBytes += count)),
|
|
75
142
|
async wait() {
|
|
76
|
-
|
|
77
|
-
return await proc.wait();
|
|
78
|
-
}
|
|
79
|
-
finally {
|
|
80
|
-
live.delete(wrapped);
|
|
81
|
-
}
|
|
143
|
+
return await exit;
|
|
82
144
|
},
|
|
83
145
|
async kill() {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
146
|
+
await wrapped.terminate("killed");
|
|
147
|
+
},
|
|
148
|
+
async terminate(reason) {
|
|
149
|
+
terminationReason = reason;
|
|
150
|
+
await proc.kill();
|
|
151
|
+
live.delete(wrapped);
|
|
90
152
|
},
|
|
91
153
|
};
|
|
92
154
|
live.add(wrapped);
|
|
155
|
+
emit({
|
|
156
|
+
type: "command.started",
|
|
157
|
+
commandId,
|
|
158
|
+
...(proc.pid === undefined ? {} : { pid: proc.pid, pgid: proc.pid }),
|
|
159
|
+
liveProcesses: live.size,
|
|
160
|
+
});
|
|
161
|
+
// Process ownership ends when the process actually exits, independently
|
|
162
|
+
// of whether the caller ever collects the returned handle.
|
|
163
|
+
void exit
|
|
164
|
+
.then((result) => {
|
|
165
|
+
live.delete(wrapped);
|
|
166
|
+
emit({
|
|
167
|
+
type: "command.finished",
|
|
168
|
+
commandId,
|
|
169
|
+
...(proc.pid === undefined ? {} : { pid: proc.pid }),
|
|
170
|
+
reason: finishReason(undefined, terminationReason),
|
|
171
|
+
exitCode: result.exitCode,
|
|
172
|
+
durationMs: Date.now() - startedAt,
|
|
173
|
+
stdoutBytes,
|
|
174
|
+
stderrBytes,
|
|
175
|
+
liveProcesses: live.size,
|
|
176
|
+
});
|
|
177
|
+
}, (error) => {
|
|
178
|
+
live.delete(wrapped);
|
|
179
|
+
emit({
|
|
180
|
+
type: "command.finished",
|
|
181
|
+
commandId,
|
|
182
|
+
...(proc.pid === undefined ? {} : { pid: proc.pid }),
|
|
183
|
+
reason: finishReason(error, terminationReason),
|
|
184
|
+
durationMs: Date.now() - startedAt,
|
|
185
|
+
stdoutBytes,
|
|
186
|
+
stderrBytes,
|
|
187
|
+
liveProcesses: live.size,
|
|
188
|
+
error: error instanceof Error ? error.message : String(error),
|
|
189
|
+
});
|
|
190
|
+
})
|
|
191
|
+
.catch(() => { });
|
|
93
192
|
return wrapped;
|
|
94
193
|
}
|
|
95
194
|
async function spawnProcess(spawnOptions) {
|
|
195
|
+
if (lifecycle !== "running") {
|
|
196
|
+
throw new Error(`bwrap sandbox: compute generation is ${lifecycle}; refusing to spawn`);
|
|
197
|
+
}
|
|
198
|
+
if (options.maxConcurrentProcesses !== null && live.size >= options.maxConcurrentProcesses) {
|
|
199
|
+
throw new Error(`bwrap sandbox: concurrent process limit of ${options.maxConcurrentProcesses} reached`);
|
|
200
|
+
}
|
|
96
201
|
const env = {
|
|
97
202
|
PATH: DEFAULT_SANDBOX_PATH,
|
|
98
203
|
HOME: WORKSPACE_ROOT,
|
|
@@ -118,21 +223,77 @@ export function createBwrapSession(input) {
|
|
|
118
223
|
return {
|
|
119
224
|
id,
|
|
120
225
|
resolvePath: resolveWorkspacePath,
|
|
226
|
+
lifecycleState() {
|
|
227
|
+
return lifecycle;
|
|
228
|
+
},
|
|
121
229
|
async killAll() {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
230
|
+
if (lifecycle === "stopped")
|
|
231
|
+
return;
|
|
232
|
+
if (cleanupPromise)
|
|
233
|
+
return await cleanupPromise;
|
|
234
|
+
lifecycle = "stopping";
|
|
235
|
+
cleanupPromise = (async () => {
|
|
236
|
+
const pending = [...live];
|
|
237
|
+
const startedAt = Date.now();
|
|
238
|
+
emit({ type: "cleanup.started", requestedProcesses: pending.length });
|
|
239
|
+
const results = await Promise.allSettled(pending.map(async (proc) => await proc.terminate("cleanup")));
|
|
240
|
+
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
241
|
+
if (failures.length > 0) {
|
|
242
|
+
const errorMessages = failures.map((failure) => failure instanceof Error ? failure.message : String(failure));
|
|
243
|
+
emit({
|
|
244
|
+
type: "cleanup.failed",
|
|
245
|
+
requestedProcesses: pending.length,
|
|
246
|
+
remainingProcesses: live.size,
|
|
247
|
+
durationMs: Date.now() - startedAt,
|
|
248
|
+
errors: errorMessages,
|
|
249
|
+
});
|
|
250
|
+
throw new AggregateError(failures, `bwrap sandbox: failed to stop all live processes: ${errorMessages.join("; ")}`);
|
|
251
|
+
}
|
|
252
|
+
if (!stoppedNotified) {
|
|
253
|
+
try {
|
|
254
|
+
await input.onStopped?.();
|
|
255
|
+
stoppedNotified = true;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
259
|
+
emit({
|
|
260
|
+
type: "cleanup.failed",
|
|
261
|
+
requestedProcesses: pending.length,
|
|
262
|
+
remainingProcesses: live.size,
|
|
263
|
+
durationMs: Date.now() - startedAt,
|
|
264
|
+
errors: [message],
|
|
265
|
+
});
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
lifecycle = "stopped";
|
|
270
|
+
emit({
|
|
271
|
+
type: "cleanup.completed",
|
|
272
|
+
requestedProcesses: pending.length,
|
|
273
|
+
remainingProcesses: live.size,
|
|
274
|
+
durationMs: Date.now() - startedAt,
|
|
275
|
+
});
|
|
276
|
+
})();
|
|
277
|
+
try {
|
|
278
|
+
await cleanupPromise;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
cleanupPromise = undefined;
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
125
284
|
},
|
|
126
285
|
async spawn(spawnOptions) {
|
|
127
286
|
return await spawnProcess(spawnOptions);
|
|
128
287
|
},
|
|
129
288
|
async run(runOptions) {
|
|
130
|
-
const
|
|
289
|
+
const outputAbort = new AbortController();
|
|
290
|
+
const runAbort = createRunAbortSignal(runOptions.abortSignal, options.runTimeoutMs, outputAbort.signal);
|
|
291
|
+
const budget = { limit: options.maxOutputBytes, used: 0 };
|
|
131
292
|
try {
|
|
132
293
|
const proc = await spawnProcess({ ...runOptions, abortSignal: runAbort.signal });
|
|
133
294
|
const [stdout, stderr, { exitCode }] = await Promise.all([
|
|
134
|
-
collectStream(proc.stdout),
|
|
135
|
-
collectStream(proc.stderr),
|
|
295
|
+
collectStream(proc.stdout, budget, (reason) => outputAbort.abort(reason)),
|
|
296
|
+
collectStream(proc.stderr, budget, (reason) => outputAbort.abort(reason)),
|
|
136
297
|
proc.wait(),
|
|
137
298
|
]);
|
|
138
299
|
return { exitCode, stdout, stderr };
|