@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/cache.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants, existsSync } from "node:fs";
|
|
3
|
+
import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
const activeCachePaths = new Map();
|
|
6
|
+
export function registerActiveCachePath(path) {
|
|
7
|
+
const resolved = resolve(path);
|
|
8
|
+
activeCachePaths.set(resolved, (activeCachePaths.get(resolved) ?? 0) + 1);
|
|
9
|
+
let released = false;
|
|
10
|
+
return () => {
|
|
11
|
+
if (released)
|
|
12
|
+
return;
|
|
13
|
+
released = true;
|
|
14
|
+
const remaining = (activeCachePaths.get(resolved) ?? 1) - 1;
|
|
15
|
+
if (remaining <= 0)
|
|
16
|
+
activeCachePaths.delete(resolved);
|
|
17
|
+
else
|
|
18
|
+
activeCachePaths.set(resolved, remaining);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function resolveCacheRoot(input) {
|
|
22
|
+
return resolve(input.cacheDir ?? join(input.appRoot, ".eve", "sandbox-cache", "bwrap"));
|
|
23
|
+
}
|
|
24
|
+
function leaseDirectory(cacheRoot) {
|
|
25
|
+
return join(cacheRoot, "metadata", "active", "sessions");
|
|
26
|
+
}
|
|
27
|
+
export async function createBwrapCacheLease(input) {
|
|
28
|
+
const generationId = randomUUID();
|
|
29
|
+
const directory = leaseDirectory(input.cacheRoot);
|
|
30
|
+
const path = join(directory, `${input.sessionId}.${generationId}.json`);
|
|
31
|
+
const lease = {
|
|
32
|
+
sessionId: input.sessionId,
|
|
33
|
+
generationId,
|
|
34
|
+
pid: process.pid,
|
|
35
|
+
createdAt: new Date().toISOString(),
|
|
36
|
+
path,
|
|
37
|
+
};
|
|
38
|
+
await mkdir(directory, { recursive: true });
|
|
39
|
+
await writeFile(path, `${JSON.stringify(lease, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
40
|
+
let released = false;
|
|
41
|
+
return {
|
|
42
|
+
lease,
|
|
43
|
+
async release() {
|
|
44
|
+
if (released)
|
|
45
|
+
return;
|
|
46
|
+
await rm(path, { force: true });
|
|
47
|
+
released = true;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function listBwrapCacheLeases(input) {
|
|
52
|
+
const cacheRoot = resolveCacheRoot(input);
|
|
53
|
+
const directory = leaseDirectory(cacheRoot);
|
|
54
|
+
const names = await readdir(directory).catch((error) => {
|
|
55
|
+
if (error.code === "ENOENT")
|
|
56
|
+
return [];
|
|
57
|
+
throw error;
|
|
58
|
+
});
|
|
59
|
+
const leases = await Promise.all(names
|
|
60
|
+
.filter((name) => name.endsWith(".json"))
|
|
61
|
+
.map(async (name) => {
|
|
62
|
+
const path = join(directory, name);
|
|
63
|
+
try {
|
|
64
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
65
|
+
if (typeof value.sessionId !== "string" ||
|
|
66
|
+
!validEntryId("session", value.sessionId) ||
|
|
67
|
+
typeof value.generationId !== "string" ||
|
|
68
|
+
typeof value.pid !== "number" ||
|
|
69
|
+
typeof value.createdAt !== "string") {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return { ...value, path };
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}));
|
|
78
|
+
return leases
|
|
79
|
+
.filter((lease) => lease !== null)
|
|
80
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
81
|
+
}
|
|
82
|
+
function validEntryId(kind, id) {
|
|
83
|
+
return kind === "session" ? /^[0-9a-f]{32}$/.test(id) : /^[0-9a-f]{32}-[0-9a-f]{16}$/.test(id);
|
|
84
|
+
}
|
|
85
|
+
async function directorySize(path) {
|
|
86
|
+
const info = await lstat(path);
|
|
87
|
+
if (!info.isDirectory())
|
|
88
|
+
return info.size;
|
|
89
|
+
const children = await readdir(path);
|
|
90
|
+
const sizes = await Promise.all(children.map(async (child) => await directorySize(join(path, child))));
|
|
91
|
+
return sizes.reduce((total, size) => total + size, 0);
|
|
92
|
+
}
|
|
93
|
+
async function listKind(cacheRoot, kind, leasedSessionIds) {
|
|
94
|
+
const directory = join(cacheRoot, `${kind}s`);
|
|
95
|
+
const children = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
96
|
+
if (error.code === "ENOENT")
|
|
97
|
+
return [];
|
|
98
|
+
throw error;
|
|
99
|
+
});
|
|
100
|
+
const entries = await Promise.all(children
|
|
101
|
+
.filter((child) => child.isDirectory() && validEntryId(kind, child.name))
|
|
102
|
+
.map(async (child) => {
|
|
103
|
+
const entryPath = join(directory, child.name);
|
|
104
|
+
const info = await lstat(entryPath);
|
|
105
|
+
const stored = await readMetadata(metadataPath(cacheRoot, kind, child.name));
|
|
106
|
+
const metadata = stored?.kind === kind && stored.id === child.name
|
|
107
|
+
? stored
|
|
108
|
+
: {
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
kind,
|
|
111
|
+
id: child.name,
|
|
112
|
+
createdAt: info.birthtime.toISOString(),
|
|
113
|
+
lastUsedAt: info.mtime.toISOString(),
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
...metadata,
|
|
117
|
+
path: entryPath,
|
|
118
|
+
sizeBytes: await directorySize(entryPath),
|
|
119
|
+
active: activeCachePaths.has(entryPath) || leasedSessionIds.has(child.name),
|
|
120
|
+
metadataPresent: stored !== null && stored.kind === kind && stored.id === child.name,
|
|
121
|
+
};
|
|
122
|
+
}));
|
|
123
|
+
return entries.sort((a, b) => a.id.localeCompare(b.id));
|
|
124
|
+
}
|
|
125
|
+
export async function listBwrapCache(input) {
|
|
126
|
+
const cacheRoot = resolveCacheRoot(input);
|
|
127
|
+
const leases = await listBwrapCacheLeases(input);
|
|
128
|
+
const leasedSessionIds = new Set(leases.map((lease) => lease.sessionId));
|
|
129
|
+
const [sessions, templates] = await Promise.all([
|
|
130
|
+
listKind(cacheRoot, "session", leasedSessionIds),
|
|
131
|
+
listKind(cacheRoot, "template", leasedSessionIds),
|
|
132
|
+
]);
|
|
133
|
+
return [...sessions, ...templates];
|
|
134
|
+
}
|
|
135
|
+
function validatePolicy(kind, policy) {
|
|
136
|
+
if (policy.maxAgeMs !== undefined &&
|
|
137
|
+
(!Number.isSafeInteger(policy.maxAgeMs) || policy.maxAgeMs < 0)) {
|
|
138
|
+
throw new Error(`bwrap sandbox: ${kind} maxAgeMs must be a non-negative safe integer`);
|
|
139
|
+
}
|
|
140
|
+
if (policy.maxEntries !== undefined &&
|
|
141
|
+
(!Number.isSafeInteger(policy.maxEntries) || policy.maxEntries < 0)) {
|
|
142
|
+
throw new Error(`bwrap sandbox: ${kind} maxEntries must be a non-negative safe integer`);
|
|
143
|
+
}
|
|
144
|
+
if (policy.maxAgeMs === undefined && policy.maxEntries === undefined) {
|
|
145
|
+
throw new Error(`bwrap sandbox: ${kind} prune policy must set maxAgeMs or maxEntries`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function selectCandidates(entries, policy, nowMs) {
|
|
149
|
+
const selected = new Set();
|
|
150
|
+
if (policy.maxAgeMs !== undefined) {
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (nowMs - Date.parse(entry.lastUsedAt) > policy.maxAgeMs)
|
|
153
|
+
selected.add(entry);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (policy.maxEntries !== undefined && entries.length > policy.maxEntries) {
|
|
157
|
+
const oldestFirst = [...entries].sort((a, b) => Date.parse(a.lastUsedAt) - Date.parse(b.lastUsedAt) || a.id.localeCompare(b.id));
|
|
158
|
+
for (const entry of oldestFirst.slice(0, entries.length - policy.maxEntries)) {
|
|
159
|
+
selected.add(entry);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return selected;
|
|
163
|
+
}
|
|
164
|
+
export async function pruneBwrapCache(input) {
|
|
165
|
+
if (!input.sessions && !input.templates) {
|
|
166
|
+
throw new Error("bwrap sandbox: cache prune requires a sessions or templates policy");
|
|
167
|
+
}
|
|
168
|
+
if (input.sessions)
|
|
169
|
+
validatePolicy("session", input.sessions);
|
|
170
|
+
if (input.templates)
|
|
171
|
+
validatePolicy("template", input.templates);
|
|
172
|
+
const entries = await listBwrapCache(input);
|
|
173
|
+
const nowMs = (input.now ?? new Date()).getTime();
|
|
174
|
+
const selected = new Set();
|
|
175
|
+
for (const [kind, policy] of [
|
|
176
|
+
["session", input.sessions],
|
|
177
|
+
["template", input.templates],
|
|
178
|
+
]) {
|
|
179
|
+
if (!policy)
|
|
180
|
+
continue;
|
|
181
|
+
const matches = entries.filter((entry) => entry.kind === kind);
|
|
182
|
+
for (const entry of selectCandidates(matches, policy, nowMs))
|
|
183
|
+
selected.add(entry);
|
|
184
|
+
}
|
|
185
|
+
const candidates = [...selected].sort((a, b) => a.path.localeCompare(b.path));
|
|
186
|
+
const skippedActive = candidates.filter((entry) => entry.active || activeCachePaths.has(entry.path));
|
|
187
|
+
const removable = candidates.filter((entry) => !skippedActive.includes(entry));
|
|
188
|
+
const removed = [];
|
|
189
|
+
if (input.dryRun === false) {
|
|
190
|
+
const cacheRoot = resolveCacheRoot(input);
|
|
191
|
+
for (const entry of removable) {
|
|
192
|
+
const leased = (await listBwrapCacheLeases(input)).some((lease) => lease.sessionId === entry.id);
|
|
193
|
+
if (activeCachePaths.has(entry.path) || leased) {
|
|
194
|
+
skippedActive.push(entry);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
await rm(entry.path, { force: true, recursive: true });
|
|
198
|
+
await rm(metadataPath(cacheRoot, entry.kind, entry.id), { force: true });
|
|
199
|
+
removed.push(entry);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
dryRun: input.dryRun !== false,
|
|
204
|
+
candidates,
|
|
205
|
+
removed,
|
|
206
|
+
skippedActive,
|
|
207
|
+
retained: entries.filter((entry) => !removed.includes(entry) && (!selected.has(entry) || skippedActive.includes(entry))),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const reflinkFallbackCodes = new Set(["EINVAL", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV"]);
|
|
211
|
+
export async function cloneDirectoryAtomically(input) {
|
|
212
|
+
const copyDirectory = input.copyDirectory ?? (async (source, target, options) => await cp(source, target, options));
|
|
213
|
+
const temporary = `${input.targetPath}.${randomUUID()}.tmp`;
|
|
214
|
+
await mkdir(dirname(input.targetPath), { recursive: true });
|
|
215
|
+
try {
|
|
216
|
+
let strategy = "reflink";
|
|
217
|
+
try {
|
|
218
|
+
await copyDirectory(input.sourcePath, temporary, {
|
|
219
|
+
recursive: true,
|
|
220
|
+
mode: constants.COPYFILE_FICLONE_FORCE,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (!reflinkFallbackCodes.has(error.code ?? ""))
|
|
225
|
+
throw error;
|
|
226
|
+
strategy = "copy";
|
|
227
|
+
await rm(temporary, { force: true, recursive: true });
|
|
228
|
+
await copyDirectory(input.sourcePath, temporary, { recursive: true });
|
|
229
|
+
}
|
|
230
|
+
await rename(temporary, input.targetPath);
|
|
231
|
+
return strategy;
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
await rm(temporary, { force: true, recursive: true }).catch(() => { });
|
|
235
|
+
if (existsSync(input.targetPath))
|
|
236
|
+
return "existing";
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function metadataPath(cacheRoot, kind, id) {
|
|
241
|
+
return join(cacheRoot, "metadata", `${kind}s`, `${id}.json`);
|
|
242
|
+
}
|
|
243
|
+
async function readMetadata(path) {
|
|
244
|
+
try {
|
|
245
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
246
|
+
return value.schemaVersion === 1 ? value : null;
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
if (error.code === "ENOENT")
|
|
250
|
+
return null;
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export async function touchCacheMetadata(input) {
|
|
255
|
+
const target = metadataPath(input.cacheRoot, input.kind, input.id);
|
|
256
|
+
const existing = await readMetadata(target);
|
|
257
|
+
const now = (input.now ?? new Date()).toISOString();
|
|
258
|
+
const tags = input.tags ?? existing?.tags;
|
|
259
|
+
const cloneStrategy = input.cloneStrategy === "existing"
|
|
260
|
+
? (existing?.cloneStrategy ?? "existing")
|
|
261
|
+
: (input.cloneStrategy ?? existing?.cloneStrategy);
|
|
262
|
+
const templateRevision = input.templateRevision ?? existing?.templateRevision;
|
|
263
|
+
const metadata = {
|
|
264
|
+
schemaVersion: 1,
|
|
265
|
+
kind: input.kind,
|
|
266
|
+
id: input.id,
|
|
267
|
+
createdAt: existing?.createdAt ?? now,
|
|
268
|
+
lastUsedAt: now,
|
|
269
|
+
...(tags && Object.keys(tags).length > 0 ? { tags } : {}),
|
|
270
|
+
...(cloneStrategy ? { cloneStrategy } : {}),
|
|
271
|
+
...(templateRevision ? { templateRevision } : {}),
|
|
272
|
+
};
|
|
273
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
274
|
+
await mkdir(dirname(target), { recursive: true });
|
|
275
|
+
try {
|
|
276
|
+
await writeFile(temporary, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
277
|
+
await rename(temporary, target);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
await rm(temporary, { force: true }).catch(() => { });
|
|
281
|
+
}
|
|
282
|
+
return metadata;
|
|
283
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type BwrapCommandFinishReason = "exit" | "abort" | "timeout" | "output-limit" | "killed" | "cleanup";
|
|
2
|
+
interface BwrapEventBase {
|
|
3
|
+
readonly timestamp: string;
|
|
4
|
+
readonly sessionId: string;
|
|
5
|
+
readonly generationId: string;
|
|
6
|
+
readonly tags: Readonly<Record<string, string>>;
|
|
7
|
+
}
|
|
8
|
+
export type BwrapSandboxEvent = (BwrapEventBase & {
|
|
9
|
+
readonly type: "generation.started";
|
|
10
|
+
}) | (BwrapEventBase & {
|
|
11
|
+
readonly type: "command.started";
|
|
12
|
+
readonly commandId: string;
|
|
13
|
+
readonly pid?: number;
|
|
14
|
+
readonly pgid?: number;
|
|
15
|
+
readonly liveProcesses: number;
|
|
16
|
+
}) | (BwrapEventBase & {
|
|
17
|
+
readonly type: "command.finished";
|
|
18
|
+
readonly commandId: string;
|
|
19
|
+
readonly pid?: number;
|
|
20
|
+
readonly reason: BwrapCommandFinishReason;
|
|
21
|
+
readonly exitCode?: number;
|
|
22
|
+
readonly durationMs: number;
|
|
23
|
+
readonly stdoutBytes: number;
|
|
24
|
+
readonly stderrBytes: number;
|
|
25
|
+
readonly liveProcesses: number;
|
|
26
|
+
readonly error?: string;
|
|
27
|
+
}) | (BwrapEventBase & {
|
|
28
|
+
readonly type: "cleanup.started";
|
|
29
|
+
readonly requestedProcesses: number;
|
|
30
|
+
}) | (BwrapEventBase & {
|
|
31
|
+
readonly type: "cleanup.completed";
|
|
32
|
+
readonly requestedProcesses: number;
|
|
33
|
+
readonly remainingProcesses: number;
|
|
34
|
+
readonly durationMs: number;
|
|
35
|
+
}) | (BwrapEventBase & {
|
|
36
|
+
readonly type: "cleanup.failed";
|
|
37
|
+
readonly requestedProcesses: number;
|
|
38
|
+
readonly remainingProcesses: number;
|
|
39
|
+
readonly durationMs: number;
|
|
40
|
+
readonly errors: readonly string[];
|
|
41
|
+
});
|
|
42
|
+
export type BwrapSandboxEventPayload = BwrapSandboxEvent extends infer Event ? Event extends BwrapSandboxEvent ? Omit<Event, "timestamp" | "sessionId" | "generationId" | "tags"> : never : never;
|
|
43
|
+
export type BwrapSandboxEventSink = (event: BwrapSandboxEvent) => unknown;
|
|
44
|
+
export {};
|
package/dist/events.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { SandboxBackend } from "eve/sandbox";
|
|
2
|
-
import type { BwrapSandboxCreateOptions } from "./options.js";
|
|
2
|
+
import type { BwrapSandboxCreateOptions, BwrapSandboxUseOptions } from "./options.js";
|
|
3
3
|
export { BWRAP_BACKEND_NAME, createBwrapSandboxBackend, type CreateBwrapSandboxBackendInput, } from "./backend.js";
|
|
4
|
-
export type { BwrapNetworkPolicy, BwrapSandboxCreateOptions } from "./options.js";
|
|
5
|
-
export {
|
|
4
|
+
export type { BwrapNetworkPolicy, BwrapSandboxCreateOptions, BwrapSandboxUseOptions, } from "./options.js";
|
|
5
|
+
export type { BwrapCommandFinishReason, BwrapSandboxEvent, BwrapSandboxEventSink, } from "./events.js";
|
|
6
|
+
export { listBwrapCache, listBwrapCacheLeases, pruneBwrapCache } from "./cache.js";
|
|
7
|
+
export type { BwrapCacheEntry, BwrapCacheEntryKind, BwrapCacheLocation, BwrapCacheLease, BwrapCacheMetadata, BwrapCachePruneInput, BwrapCachePrunePolicy, BwrapCachePruneResult, BwrapCloneStrategy, } from "./cache.js";
|
|
8
|
+
export { DEFAULT_MAX_CONCURRENT_PROCESSES, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_RUN_TIMEOUT_MS, } from "./options.js";
|
|
6
9
|
export { isBwrapAvailable } from "./process.js";
|
|
7
10
|
export type { ProcessRunner, SpawnedProcess } from "./process.js";
|
|
8
11
|
/**
|
|
@@ -18,4 +21,4 @@ export type { ProcessRunner, SpawnedProcess } from "./process.js";
|
|
|
18
21
|
* });
|
|
19
22
|
* ```
|
|
20
23
|
*/
|
|
21
|
-
export declare function bwrap(options?: BwrapSandboxCreateOptions): SandboxBackend
|
|
24
|
+
export declare function bwrap(options?: BwrapSandboxCreateOptions): SandboxBackend<BwrapSandboxUseOptions, BwrapSandboxUseOptions>;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createBwrapSandboxBackend } from "./backend.js";
|
|
2
2
|
export { BWRAP_BACKEND_NAME, createBwrapSandboxBackend, } from "./backend.js";
|
|
3
|
-
export {
|
|
3
|
+
export { listBwrapCache, listBwrapCacheLeases, pruneBwrapCache } from "./cache.js";
|
|
4
|
+
export { DEFAULT_MAX_CONCURRENT_PROCESSES, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_RUN_TIMEOUT_MS, } from "./options.js";
|
|
4
5
|
export { isBwrapAvailable } from "./process.js";
|
|
5
6
|
/**
|
|
6
7
|
* Creates the bubblewrap sandbox backend for `defineSandbox({ backend })`.
|
package/dist/options.d.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
import type { BwrapSandboxEventSink } from "./events.js";
|
|
1
2
|
/** Coarse egress control, matching what eve's Docker backend supports. */
|
|
2
3
|
export type BwrapNetworkPolicy = "allow-all" | "deny-all";
|
|
4
|
+
/** Options lifecycle hooks can apply when they open a template or live Session. */
|
|
5
|
+
export interface BwrapSandboxUseOptions {
|
|
6
|
+
/** Network policy used by subsequent commands in this lifecycle callback. */
|
|
7
|
+
readonly networkPolicy?: BwrapNetworkPolicy;
|
|
8
|
+
}
|
|
3
9
|
/** Commands executed through `run()` are bounded by default; use `spawn()` for daemons. */
|
|
4
10
|
export declare const DEFAULT_RUN_TIMEOUT_MS = 600000;
|
|
11
|
+
/** Per-generation admission ceiling for transient bwrap commands. */
|
|
12
|
+
export declare const DEFAULT_MAX_CONCURRENT_PROCESSES = 64;
|
|
13
|
+
/** Combined stdout + stderr retained by one `run()` call. */
|
|
14
|
+
export declare const DEFAULT_MAX_OUTPUT_BYTES: number;
|
|
5
15
|
/** Options accepted by `bwrap(opts)`. */
|
|
6
16
|
export interface BwrapSandboxCreateOptions {
|
|
7
17
|
/** Environment variables set for every command the backend runs. */
|
|
@@ -31,6 +41,12 @@ export interface BwrapSandboxCreateOptions {
|
|
|
31
41
|
* long-running `spawn()` API.
|
|
32
42
|
*/
|
|
33
43
|
readonly runTimeoutMs?: number | null;
|
|
44
|
+
/** Maximum live `run()`/`spawn()` processes in one compute generation. */
|
|
45
|
+
readonly maxConcurrentProcesses?: number | null;
|
|
46
|
+
/** Maximum combined stdout/stderr bytes retained by one `run()` call. */
|
|
47
|
+
readonly maxOutputBytes?: number | null;
|
|
48
|
+
/** Best-effort structured lifecycle events. Sink errors never affect commands. */
|
|
49
|
+
readonly onEvent?: BwrapSandboxEventSink;
|
|
34
50
|
}
|
|
35
51
|
/** Fully-defaulted options consumed by the backend implementation. */
|
|
36
52
|
export interface ResolvedBwrapSandboxOptions {
|
|
@@ -41,6 +57,9 @@ export interface ResolvedBwrapSandboxOptions {
|
|
|
41
57
|
readonly cacheDir: string | null;
|
|
42
58
|
readonly templateRevision: string | null;
|
|
43
59
|
readonly runTimeoutMs: number | null;
|
|
60
|
+
readonly maxConcurrentProcesses: number | null;
|
|
61
|
+
readonly maxOutputBytes: number | null;
|
|
62
|
+
readonly onEvent?: BwrapSandboxEventSink;
|
|
44
63
|
}
|
|
45
64
|
export declare function resolveBwrapSandboxOptions(options?: BwrapSandboxCreateOptions): ResolvedBwrapSandboxOptions;
|
|
46
65
|
/**
|
package/dist/options.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
/** Commands executed through `run()` are bounded by default; use `spawn()` for daemons. */
|
|
3
3
|
export const DEFAULT_RUN_TIMEOUT_MS = 600_000;
|
|
4
|
+
/** Per-generation admission ceiling for transient bwrap commands. */
|
|
5
|
+
export const DEFAULT_MAX_CONCURRENT_PROCESSES = 64;
|
|
6
|
+
/** Combined stdout + stderr retained by one `run()` call. */
|
|
7
|
+
export const DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
4
8
|
function resolveRunTimeoutMs(value) {
|
|
5
9
|
if (value === null)
|
|
6
10
|
return null;
|
|
@@ -10,6 +14,15 @@ function resolveRunTimeoutMs(value) {
|
|
|
10
14
|
}
|
|
11
15
|
return resolved;
|
|
12
16
|
}
|
|
17
|
+
function resolvePositiveLimit(name, value, fallback) {
|
|
18
|
+
if (value === null)
|
|
19
|
+
return null;
|
|
20
|
+
const resolved = value ?? fallback;
|
|
21
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
22
|
+
throw new Error(`bwrap sandbox: ${name} must be a positive safe integer or null`);
|
|
23
|
+
}
|
|
24
|
+
return resolved;
|
|
25
|
+
}
|
|
13
26
|
export function resolveBwrapSandboxOptions(options = {}) {
|
|
14
27
|
return {
|
|
15
28
|
env: options.env ?? {},
|
|
@@ -19,6 +32,9 @@ export function resolveBwrapSandboxOptions(options = {}) {
|
|
|
19
32
|
cacheDir: options.cacheDir ?? null,
|
|
20
33
|
templateRevision: options.templateRevision ?? null,
|
|
21
34
|
runTimeoutMs: resolveRunTimeoutMs(options.runTimeoutMs),
|
|
35
|
+
maxConcurrentProcesses: resolvePositiveLimit("maxConcurrentProcesses", options.maxConcurrentProcesses, DEFAULT_MAX_CONCURRENT_PROCESSES),
|
|
36
|
+
maxOutputBytes: resolvePositiveLimit("maxOutputBytes", options.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES),
|
|
37
|
+
...(options.onEvent ? { onEvent: options.onEvent } : {}),
|
|
22
38
|
};
|
|
23
39
|
}
|
|
24
40
|
/**
|
|
@@ -32,6 +48,8 @@ export function createBwrapOptionsHash(options) {
|
|
|
32
48
|
cacheDir: options.cacheDir,
|
|
33
49
|
env: Object.fromEntries(Object.entries(options.env).sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
34
50
|
hidePaths: [...options.hidePaths],
|
|
51
|
+
maxConcurrentProcesses: options.maxConcurrentProcesses,
|
|
52
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
35
53
|
networkPolicy: options.networkPolicy,
|
|
36
54
|
runTimeoutMs: options.runTimeoutMs,
|
|
37
55
|
templateRevision: options.templateRevision,
|
package/dist/process.js
CHANGED
|
@@ -39,44 +39,68 @@ export function createNodeProcessRunner() {
|
|
|
39
39
|
});
|
|
40
40
|
exit.catch(() => { });
|
|
41
41
|
const killTree = () => {
|
|
42
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
43
|
+
return undefined;
|
|
42
44
|
if (child.pid !== undefined) {
|
|
43
45
|
try {
|
|
44
46
|
process.kill(-child.pid, "SIGKILL");
|
|
45
|
-
return;
|
|
47
|
+
return undefined;
|
|
46
48
|
}
|
|
47
|
-
catch {
|
|
48
|
-
//
|
|
49
|
+
catch (error) {
|
|
50
|
+
// ESRCH can mean the process group was not established yet; kill
|
|
51
|
+
// the direct child. Other failures (notably EPERM) are cleanup
|
|
52
|
+
// failures even if the direct-child fallback succeeds, because
|
|
53
|
+
// descendants may have escaped the signal.
|
|
54
|
+
child.kill("SIGKILL");
|
|
55
|
+
if (error.code !== "ESRCH")
|
|
56
|
+
return error;
|
|
57
|
+
return undefined;
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
60
|
child.kill("SIGKILL");
|
|
61
|
+
return undefined;
|
|
52
62
|
};
|
|
53
63
|
let aborted = false;
|
|
54
64
|
let abortReason;
|
|
65
|
+
let abortKillError;
|
|
55
66
|
const signal = options?.abortSignal;
|
|
67
|
+
let onAbort;
|
|
56
68
|
if (signal) {
|
|
57
|
-
|
|
69
|
+
onAbort = () => {
|
|
58
70
|
aborted = true;
|
|
59
71
|
abortReason = signal.reason;
|
|
60
|
-
killTree();
|
|
72
|
+
abortKillError = killTree();
|
|
61
73
|
};
|
|
62
74
|
if (signal.aborted)
|
|
63
75
|
onAbort();
|
|
64
76
|
else
|
|
65
77
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
66
78
|
}
|
|
79
|
+
const removeAbortListener = () => {
|
|
80
|
+
if (signal && onAbort)
|
|
81
|
+
signal.removeEventListener("abort", onAbort);
|
|
82
|
+
};
|
|
83
|
+
void exit.then(removeAbortListener, removeAbortListener);
|
|
67
84
|
return {
|
|
68
85
|
pid: child.pid,
|
|
69
86
|
stdout: Readable.toWeb(child.stdout),
|
|
70
87
|
stderr: Readable.toWeb(child.stderr),
|
|
71
88
|
async wait() {
|
|
72
89
|
const result = await exit;
|
|
90
|
+
if (abortKillError !== undefined) {
|
|
91
|
+
const cleanupMessage = abortKillError instanceof Error ? abortKillError.message : String(abortKillError);
|
|
92
|
+
const abortMessage = abortReason instanceof Error ? abortReason.message : String(abortReason);
|
|
93
|
+
throw new AggregateError([abortKillError, abortReason], `bwrap process abort cleanup failed: ${cleanupMessage}; abort reason: ${abortMessage}`);
|
|
94
|
+
}
|
|
73
95
|
if (aborted)
|
|
74
96
|
throw abortReason;
|
|
75
97
|
return result;
|
|
76
98
|
},
|
|
77
99
|
async kill() {
|
|
78
|
-
killTree();
|
|
100
|
+
const killError = killTree();
|
|
79
101
|
await exit.catch(() => { });
|
|
102
|
+
if (killError !== undefined)
|
|
103
|
+
throw killError;
|
|
80
104
|
},
|
|
81
105
|
};
|
|
82
106
|
},
|
package/dist/session.d.ts
CHANGED
|
@@ -7,6 +7,9 @@ export interface CreateBwrapSessionInput {
|
|
|
7
7
|
readonly appRoot: string;
|
|
8
8
|
readonly runner: ProcessRunner;
|
|
9
9
|
readonly options: ResolvedBwrapSandboxOptions;
|
|
10
|
+
readonly generationId?: string;
|
|
11
|
+
readonly tags?: Readonly<Record<string, string>>;
|
|
12
|
+
readonly onStopped?: () => void | Promise<void>;
|
|
10
13
|
}
|
|
11
14
|
/**
|
|
12
15
|
* A sandbox session plus the lifecycle hook the backend handle needs.
|
|
@@ -16,5 +19,7 @@ export interface CreateBwrapSessionInput {
|
|
|
16
19
|
export type BwrapSession = SandboxSession & {
|
|
17
20
|
/** Kills every process this session spawned that has not yet exited. Idempotent. */
|
|
18
21
|
killAll(): Promise<void>;
|
|
22
|
+
/** Internal compute-generation state used to coordinate repeated handles. */
|
|
23
|
+
lifecycleState(): "running" | "stopping" | "stopped";
|
|
19
24
|
};
|
|
20
25
|
export declare function createBwrapSession(input: CreateBwrapSessionInput): BwrapSession;
|