@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/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
- for (;;) {
18
- const { done, value } = await reader.read();
19
- if (done)
20
- break;
21
- chunks.push(value);
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
- if (timeoutMs === null)
39
- return { signal: callerSignal, clear() { } };
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: callerSignal ? AbortSignal.any([callerSignal, timeout.signal]) : timeout.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
- // Wrap wait()/kill() rather than eagerly calling proc.wait() ourselves to
63
- // watch for exit: a fire-and-forget wait() started at spawn time races
64
- // ahead of the caller (its resolution is not tied to when anyone actually
65
- // observes the process), so a process can be silently untracked before
66
- // killAll() ever sees it. Tying removal to the caller's own wait()/kill()
67
- // call keeps "still tracked" in sync with "still owned by the caller":
68
- // run() untracks itself the moment its internal wait() settles, and a
69
- // bare spawn() stays tracked (and killable) until its holder collects it.
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
- try {
77
- return await proc.wait();
78
- }
79
- finally {
80
- live.delete(wrapped);
81
- }
143
+ return await exit;
82
144
  },
83
145
  async kill() {
84
- try {
85
- await proc.kill();
86
- }
87
- finally {
88
- live.delete(wrapped);
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
- const pending = [...live];
123
- live.clear();
124
- await Promise.all(pending.map((proc) => proc.kill().catch(() => undefined)));
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 runAbort = createRunAbortSignal(runOptions.abortSignal, options.runTimeoutMs);
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evelandhq/sandbox-bwrap",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "bubblewrap SandboxBackend for eve agents — real exec sandboxing without Docker or KVM",
5
5
  "keywords": [
6
6
  "agent",