@evelandhq/sandbox-bwrap 0.1.3 → 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 +87 -37
- package/dist/backend.d.ts +3 -0
- package/dist/backend.js +70 -25
- 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 +4 -1
- package/dist/index.js +2 -1
- package/dist/options.d.ts +14 -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/README.md
CHANGED
|
@@ -71,20 +71,25 @@ which requires that nothing be left running afterwards, and `stop()`, which auth
|
|
|
71
71
|
triggers mid-run through `ctx.getSandbox().stop()`. Backends with provider-side compute
|
|
72
72
|
distinguish the two — a container to pause, a VM to snapshot; bwrap has no such resource,
|
|
73
73
|
because the processes are the compute. Neither method touches the session's workspace
|
|
74
|
-
directory — it is durable state
|
|
75
|
-
next
|
|
74
|
+
directory — it is durable state. Cleanup closes that compute generation to new commands;
|
|
75
|
+
the next backend `create()` opens a fresh generation over the same workspace. Repeated
|
|
76
|
+
`create()` calls through one backend instance share the live generation, so one handle
|
|
77
|
+
cannot race a new spawn past another handle's cleanup barrier.
|
|
76
78
|
|
|
77
79
|
### Options
|
|
78
80
|
|
|
79
|
-
| Option
|
|
80
|
-
|
|
|
81
|
-
| `env`
|
|
82
|
-
| `networkPolicy`
|
|
83
|
-
| `hidePaths`
|
|
84
|
-
| `bwrapPath`
|
|
85
|
-
| `cacheDir`
|
|
86
|
-
| `templateRevision`
|
|
87
|
-
| `runTimeoutMs`
|
|
81
|
+
| Option | Default | Meaning |
|
|
82
|
+
| ------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| `env` | `{}` | Environment variables set for every sandboxed command. |
|
|
84
|
+
| `networkPolicy` | `"allow-all"` | `"allow-all"` shares the host network; `"deny-all"` runs each command with no network (`--unshare-net`). `setNetworkPolicy` can switch between the two at run time; granular domain policies are rejected (use the Vercel backend for those). |
|
|
85
|
+
| `hidePaths` | `[]` | Extra host paths hidden from the sandbox (each covered by an empty tmpfs). |
|
|
86
|
+
| `bwrapPath` | `"bwrap"` | bwrap executable to invoke. |
|
|
87
|
+
| `cacheDir` | `<appRoot>/.eve/sandbox-cache/bwrap` | Absolute directory holding templates and durable session workspaces. Pin this outside the release directory so a redeploy does not discard durable session state: since eve 0.22.0, eve keys session sandboxes per durable session, not per deployment, so an `appRoot`-derived default would silently destroy every session's `/workspace` on the next redeploy. The generated eveland module always sets this from `EVELAND_SANDBOX_CACHE_DIR`. |
|
|
88
|
+
| `templateRevision` | `null` | Optional immutable release identity included in the template cache key but not the session path. Change it when seed files change so new Sessions use a fresh template without overwriting durable workspaces. Eveland sets it from its internal `EVELAND_SANDBOX_TEMPLATE_REVISION`. |
|
|
89
|
+
| `runTimeoutMs` | `600000` | Hard wall-clock limit for one `run()` command. Timeout aborts the command and kills its complete bwrap process group. Set `null` to disable it. The limit deliberately does not apply to `spawn()`, which is the API for long-running processes. |
|
|
90
|
+
| `maxConcurrentProcesses` | `64` | Maximum live `run()`/`spawn()` commands admitted in one compute generation. A live `spawn()` counts until it actually exits, even when its caller never waits. Set `null` to disable. |
|
|
91
|
+
| `maxOutputBytes` | `16777216` | Maximum combined stdout and stderr retained by one `run()` call. Exceeding it aborts and reaps the complete process group. Set `null` to disable. Streaming `spawn()` output is not retained by this backend. |
|
|
92
|
+
| `onEvent` | `undefined` | Best-effort structured lifecycle sink for generation, command, and cleanup events. Sink failures are ignored so telemetry cannot change command behavior. |
|
|
88
93
|
|
|
89
94
|
Both lifecycle callbacks may call `use({ networkPolicy: "allow-all" | "deny-all" })`. The
|
|
90
95
|
policy is applied before `use()` returns the template or live Session, so subsequent commands in
|
|
@@ -100,34 +105,72 @@ backend's configured policy.
|
|
|
100
105
|
`<cacheDir>/templates/<hash>` (`<cacheDir>` defaults to
|
|
101
106
|
`<appRoot>/.eve/sandbox-cache/bwrap` when the `cacheDir` option is not set). Idempotent
|
|
102
107
|
per template key + options hash; `templateRevision` participates in that hash.
|
|
103
|
-
- **create** (runtime): clones the template into
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
- **create** (runtime): atomically clones the template into
|
|
109
|
+
`<cacheDir>/sessions/<hash>` on first use. It first requests a filesystem reflink
|
|
110
|
+
(copy-on-write); filesystems without reflink support fall back to a normal recursive
|
|
111
|
+
copy. The selected strategy is recorded in cache metadata. The directory IS the durable
|
|
112
|
+
session state: it persists across reconnects and process restarts.
|
|
106
113
|
- **run/spawn**: every command is one transient bwrap invocation —
|
|
107
114
|
read-only host rootfs, the session directory bound read-write at `/workspace`,
|
|
108
115
|
tmpfs `/tmp`, PID/IPC/UTS namespaces unshared, `--die-with-parent`.
|
|
109
|
-
`run()` also applies `runTimeoutMs` and kills the entire
|
|
110
|
-
when
|
|
111
|
-
|
|
116
|
+
`run()` also applies `runTimeoutMs` and `maxOutputBytes`, and kills the entire
|
|
117
|
+
detached process group when a boundary or caller AbortSignal fires. Both APIs count
|
|
118
|
+
against `maxConcurrentProcesses`; `spawn()` remains long-running until its holder
|
|
119
|
+
calls `kill()`, the compute generation is stopped, or the backend shuts down.
|
|
112
120
|
- **File I/O** (`readTextFile`, `writeFile`, …): host-side operations on the session
|
|
113
121
|
directory; no subprocess. Writes outside `/workspace` are refused.
|
|
114
122
|
|
|
115
123
|
## Disk usage and cache management
|
|
116
124
|
|
|
117
|
-
Session and template directories persist
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
the
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
Session and template directories persist under `<cacheDir>/{sessions,templates}` across
|
|
126
|
+
process restarts and reconnects, enabling fast reattach. `stop()` and `shutdown()` never
|
|
127
|
+
delete durable state, and this package never schedules automatic deletion. On Eveland,
|
|
128
|
+
the cache lives at `EVELAND_SANDBOX_CACHE_DIR` outside every release directory, so a
|
|
129
|
+
redeploy does not touch it.
|
|
130
|
+
|
|
131
|
+
Operator metadata lives separately under `<cacheDir>/metadata`; sandboxed code cannot
|
|
132
|
+
rewrite its own retention timestamps, tags, clone strategy, or active-generation leases.
|
|
133
|
+
Use the explicit APIs to inspect and reclaim storage:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { listBwrapCache, pruneBwrapCache } from "@evelandhq/sandbox-bwrap";
|
|
137
|
+
|
|
138
|
+
const location = { appRoot: "/srv/my-agent", cacheDir: "/var/lib/eveland/sandbox/project" };
|
|
139
|
+
const entries = await listBwrapCache(location);
|
|
140
|
+
|
|
141
|
+
// Dry-run is the default. Age and LRU policies may be combined independently
|
|
142
|
+
// for durable sessions and templates.
|
|
143
|
+
const preview = await pruneBwrapCache({
|
|
144
|
+
...location,
|
|
145
|
+
sessions: { maxAgeMs: 30 * 24 * 60 * 60 * 1000 },
|
|
146
|
+
templates: { maxEntries: 10 },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Apply only after inspecting preview.candidates and preview.skippedActive.
|
|
150
|
+
const applied = await pruneBwrapCache({
|
|
151
|
+
...location,
|
|
152
|
+
sessions: { maxAgeMs: 30 * 24 * 60 * 60 * 1000 },
|
|
153
|
+
templates: { maxEntries: 10 },
|
|
154
|
+
dryRun: false,
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Active compute generations are skipped and rechecked immediately before deletion. The
|
|
159
|
+
backend uses both process-local reference counts and lease files outside the writable
|
|
160
|
+
workspace, so a separate pruning process can see active sessions. A process crash can
|
|
161
|
+
leave a conservative stale lease; inspect `listBwrapCacheLeases(location)` and confirm
|
|
162
|
+
the owning deployment is stopped before removing such a lease. Leases are not expired
|
|
163
|
+
automatically because deleting a live durable workspace is worse than retaining a false
|
|
164
|
+
positive.
|
|
165
|
+
|
|
166
|
+
## Lifecycle observability
|
|
167
|
+
|
|
168
|
+
`onEvent` receives discriminated `generation.started`, `command.started`,
|
|
169
|
+
`command.finished`, `cleanup.started`, `cleanup.completed`, and `cleanup.failed` events.
|
|
170
|
+
They include session/generation/command identities, tags, PID/process-group identity when
|
|
171
|
+
available, duration, output byte counts, live-process counts, finish reason, and cleanup
|
|
172
|
+
errors. Events deliberately omit command text and output. Do not put secrets in `tags`;
|
|
173
|
+
the sink is operator-controlled telemetry, not part of the sandbox boundary.
|
|
131
174
|
|
|
132
175
|
## Security boundary
|
|
133
176
|
|
|
@@ -158,8 +201,9 @@ Reclaiming space today requires manual intervention: identify which sessions are
|
|
|
158
201
|
container's filesystem, not the Docker host; no host root or Docker socket is mounted.
|
|
159
202
|
- CPU, memory, and PID limits are inherited from whatever cgroup the agent runs in
|
|
160
203
|
(on eveland, the deployment's Docker container or systemd unit covers sandbox
|
|
161
|
-
children too).
|
|
162
|
-
|
|
204
|
+
children too). `maxConcurrentProcesses` is an admission bound, not a kernel PID or
|
|
205
|
+
thread quota; `runTimeoutMs` and `maxOutputBytes` bound one `run()`. The backend does
|
|
206
|
+
not create a per-command cgroup or impose CPU/memory quotas on `spawn()`.
|
|
163
207
|
- Host-side write/remove calls (`writeFile`, `writeTextFile`, `writeBinaryFile`,
|
|
164
208
|
`removePath`) verify containment with a realpath-aware check
|
|
165
209
|
(`isWithinWorkspaceReal`): they resolve symlinks along the path and re-check that
|
|
@@ -184,8 +228,11 @@ Reclaiming space today requires manual intervention: identify which sessions are
|
|
|
184
228
|
Eveland's generated local Docker image installs `bubblewrap` and `bash`, creates
|
|
185
229
|
`/workspace`, and starts the outer Agent container with its default capability set
|
|
186
230
|
dropped, `SYS_ADMIN` and `NET_ADMIN` added for bwrap namespaces, `no-new-privileges`,
|
|
187
|
-
|
|
188
|
-
|
|
231
|
+
`seccomp=unconfined`, and Docker `--init`. The init process is required to reap orphaned
|
|
232
|
+
bwrap descendants; without it they can accumulate as zombies until the container PID
|
|
233
|
+
limit is exhausted. Containers created before enabling it must be recreated. This is a
|
|
234
|
+
local-development boundary; the supported Linux production topology uses the
|
|
235
|
+
unprivileged systemd path below, where host PID 1 already reaps orphans.
|
|
189
236
|
|
|
190
237
|
- Linux with unprivileged user namespaces available to the calling process. Ubuntu's
|
|
191
238
|
packaged bubblewrap (0.9.0-1ubuntu0.1 on 24.04) ships **no** AppArmor profile. Since
|
|
@@ -225,7 +272,9 @@ production topology uses the unprivileged systemd path below.
|
|
|
225
272
|
- Works under systemd hardening (`NoNewPrivileges=yes`, `ProtectSystem=strict`):
|
|
226
273
|
apt's `bwrap` is not setuid, so it needs no privilege escalation to run — but it
|
|
227
274
|
still needs the AppArmor profile above to create a user namespace as an
|
|
228
|
-
unprivileged user.
|
|
275
|
+
unprivileged user. There is no Docker-style `--init` setting to add to a systemd
|
|
276
|
+
Deployment; systemd already provides the host init/subreaper and `TasksMax` cgroup
|
|
277
|
+
boundary.
|
|
229
278
|
|
|
230
279
|
## Testing
|
|
231
280
|
|
|
@@ -236,7 +285,8 @@ production topology uses the unprivileged systemd path below.
|
|
|
236
285
|
It provisions a Lima VM (`brew install lima`), streams this worktree in, and runs the
|
|
237
286
|
test as an unprivileged user under the systemd hardening a deployed eve agent actually
|
|
238
287
|
gets — `NoNewPrivileges=yes`, `ProtectSystem=strict`, `PrivateTmp=yes`. Prints
|
|
239
|
-
`BWRAP SMOKE OK`.
|
|
288
|
+
`BWRAP SMOKE OK`. The test also churns 500 short bwrap commands and checks that the
|
|
289
|
+
zombie count returns to baseline. Run this before pushing anything that touches `src/args.ts` or
|
|
240
290
|
`src/process.ts`: CI's smoke job covers the unprivileged-user case but not the systemd
|
|
241
291
|
constraints, and argv that looks right is not the same as a kernel that accepts it.
|
|
242
292
|
- `pnpm tsx src/integration/bwrap-backend-smoke.ts` — the same test, run directly. Needs
|
package/dist/backend.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SandboxBackend } from "eve/sandbox";
|
|
2
2
|
import type { BwrapSandboxCreateOptions, BwrapSandboxUseOptions } from "./options.js";
|
|
3
3
|
import type { ProcessRunner } from "./process.js";
|
|
4
|
+
import { type BwrapDirectoryCopier } from "./cache.js";
|
|
4
5
|
/**
|
|
5
6
|
* Stable backend name. Participates in eve's template/session cache-key
|
|
6
7
|
* derivation and persisted reconnect state — never change it.
|
|
@@ -10,5 +11,7 @@ export interface CreateBwrapSandboxBackendInput {
|
|
|
10
11
|
readonly createOptions?: BwrapSandboxCreateOptions;
|
|
11
12
|
/** Injectable process launcher so backend logic is testable without bwrap. */
|
|
12
13
|
readonly runner?: ProcessRunner;
|
|
14
|
+
/** Injectable clone primitive for filesystem-capability tests. */
|
|
15
|
+
readonly copyDirectory?: BwrapDirectoryCopier;
|
|
13
16
|
}
|
|
14
17
|
export declare function createBwrapSandboxBackend(input?: CreateBwrapSandboxBackendInput): SandboxBackend<BwrapSandboxUseOptions, BwrapSandboxUseOptions>;
|
package/dist/backend.js
CHANGED
|
@@ -1,37 +1,24 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
5
|
import { SandboxTemplateNotProvisionedError } from "eve/sandbox";
|
|
6
6
|
import { createBwrapOptionsHash, resolveBwrapSandboxOptions } from "./options.js";
|
|
7
|
-
import { resolveSessionPath, resolveTemplatePath, WORKSPACE_ROOT } from "./paths.js";
|
|
7
|
+
import { resolveBwrapCacheRoot, resolveSessionPath, resolveTemplatePath, WORKSPACE_ROOT, } from "./paths.js";
|
|
8
8
|
import { createNodeProcessRunner, describeMissingPrereqs, isBwrapAvailable } from "./process.js";
|
|
9
9
|
import { createBwrapSession } from "./session.js";
|
|
10
|
+
import { cloneDirectoryAtomically, createBwrapCacheLease, registerActiveCachePath, touchCacheMetadata, } from "./cache.js";
|
|
10
11
|
const EVE_MODEL_SKILL_ROOT = "$HOME/.agents/skills";
|
|
11
12
|
/**
|
|
12
13
|
* Stable backend name. Participates in eve's template/session cache-key
|
|
13
14
|
* derivation and persisted reconnect state — never change it.
|
|
14
15
|
*/
|
|
15
16
|
export const BWRAP_BACKEND_NAME = "bwrap";
|
|
16
|
-
async function copyDirectoryAtomically(sourcePath, targetPath) {
|
|
17
|
-
const tmpPath = `${targetPath}.${randomUUID()}.tmp`;
|
|
18
|
-
await mkdir(dirname(targetPath), { recursive: true });
|
|
19
|
-
try {
|
|
20
|
-
await cp(sourcePath, tmpPath, { recursive: true });
|
|
21
|
-
await rename(tmpPath, targetPath);
|
|
22
|
-
}
|
|
23
|
-
catch (error) {
|
|
24
|
-
await rm(tmpPath, { force: true, recursive: true }).catch(() => { });
|
|
25
|
-
// A concurrent writer winning the rename race is success, not failure.
|
|
26
|
-
if (existsSync(targetPath))
|
|
27
|
-
return;
|
|
28
|
-
throw error;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
17
|
export function createBwrapSandboxBackend(input = {}) {
|
|
32
18
|
const options = resolveBwrapSandboxOptions(input.createOptions);
|
|
33
19
|
const optionsHash = createBwrapOptionsHash(options);
|
|
34
20
|
const runner = input.runner ?? createNodeProcessRunner();
|
|
21
|
+
const generations = new Map();
|
|
35
22
|
// Probe only when running against the real bwrap; injected runners skip it.
|
|
36
23
|
const shouldProbe = input.runner === undefined;
|
|
37
24
|
let probed = false;
|
|
@@ -47,8 +34,42 @@ export function createBwrapSandboxBackend(input = {}) {
|
|
|
47
34
|
throw new Error(missing);
|
|
48
35
|
probed = true;
|
|
49
36
|
}
|
|
50
|
-
function openSession(id, workspaceDir, appRoot) {
|
|
51
|
-
return createBwrapSession({
|
|
37
|
+
function openSession(id, workspaceDir, appRoot, tags, generationId, onStopped) {
|
|
38
|
+
return createBwrapSession({
|
|
39
|
+
id,
|
|
40
|
+
workspaceDir,
|
|
41
|
+
appRoot,
|
|
42
|
+
runner,
|
|
43
|
+
options,
|
|
44
|
+
tags,
|
|
45
|
+
generationId,
|
|
46
|
+
onStopped,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function openRuntimeSession(id, workspaceDir, appRoot, tags) {
|
|
50
|
+
const current = generations.get(workspaceDir);
|
|
51
|
+
if (current && current.lifecycleState() !== "stopped")
|
|
52
|
+
return current;
|
|
53
|
+
const releaseActive = registerActiveCachePath(workspaceDir);
|
|
54
|
+
const cacheRoot = resolveBwrapCacheRoot(appRoot, options.cacheDir);
|
|
55
|
+
const activeLease = await createBwrapCacheLease({
|
|
56
|
+
cacheRoot,
|
|
57
|
+
sessionId: basename(workspaceDir),
|
|
58
|
+
});
|
|
59
|
+
let session;
|
|
60
|
+
try {
|
|
61
|
+
session = openSession(id, workspaceDir, appRoot, tags, activeLease.lease.generationId, async () => {
|
|
62
|
+
releaseActive();
|
|
63
|
+
await activeLease.release();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
releaseActive();
|
|
68
|
+
await activeLease.release();
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
generations.set(workspaceDir, session);
|
|
72
|
+
return session;
|
|
52
73
|
}
|
|
53
74
|
function resolveSeedPath(seedPath) {
|
|
54
75
|
if (seedPath === EVE_MODEL_SKILL_ROOT || seedPath.startsWith(`${EVE_MODEL_SKILL_ROOT}/`)) {
|
|
@@ -78,8 +99,16 @@ export function createBwrapSandboxBackend(input = {}) {
|
|
|
78
99
|
async prewarm({ templateKey, bootstrap, seedFiles, log, runtimeContext }) {
|
|
79
100
|
assertBwrapAvailable();
|
|
80
101
|
const templatePath = resolveTemplatePath(runtimeContext.appRoot, templateKey, optionsHash, options.cacheDir);
|
|
81
|
-
|
|
102
|
+
const touchTemplate = async () => await touchCacheMetadata({
|
|
103
|
+
cacheRoot: resolveBwrapCacheRoot(runtimeContext.appRoot, options.cacheDir),
|
|
104
|
+
kind: "template",
|
|
105
|
+
id: basename(templatePath),
|
|
106
|
+
templateRevision: options.templateRevision,
|
|
107
|
+
});
|
|
108
|
+
if (existsSync(templatePath)) {
|
|
109
|
+
await touchTemplate();
|
|
82
110
|
return { reused: true };
|
|
111
|
+
}
|
|
83
112
|
log?.(`bwrap: capturing template for ${templateKey}`);
|
|
84
113
|
const stagingPath = `${templatePath}.staging-${randomUUID()}`;
|
|
85
114
|
await mkdir(stagingPath, { recursive: true });
|
|
@@ -94,18 +123,23 @@ export function createBwrapSandboxBackend(input = {}) {
|
|
|
94
123
|
catch (error) {
|
|
95
124
|
await rm(stagingPath, { force: true, recursive: true }).catch(() => { });
|
|
96
125
|
// A concurrent prewarm winning the race is reuse, not failure.
|
|
97
|
-
if (existsSync(templatePath))
|
|
126
|
+
if (existsSync(templatePath)) {
|
|
127
|
+
await touchTemplate();
|
|
98
128
|
return { reused: true };
|
|
129
|
+
}
|
|
99
130
|
throw error;
|
|
100
131
|
}
|
|
132
|
+
await touchTemplate();
|
|
101
133
|
return { reused: false };
|
|
102
134
|
},
|
|
103
|
-
async create({ templateKey, sessionKey, runtimeContext }) {
|
|
135
|
+
async create({ templateKey, sessionKey, runtimeContext, tags }) {
|
|
104
136
|
assertBwrapAvailable();
|
|
105
137
|
const sessionPath = resolveSessionPath(runtimeContext.appRoot, sessionKey, options.cacheDir);
|
|
138
|
+
let cloneStrategy = "existing";
|
|
106
139
|
if (!existsSync(sessionPath)) {
|
|
107
140
|
if (templateKey === null) {
|
|
108
141
|
await mkdir(sessionPath, { recursive: true });
|
|
142
|
+
cloneStrategy = "empty";
|
|
109
143
|
}
|
|
110
144
|
else {
|
|
111
145
|
const templatePath = resolveTemplatePath(runtimeContext.appRoot, templateKey, optionsHash, options.cacheDir);
|
|
@@ -115,10 +149,21 @@ export function createBwrapSandboxBackend(input = {}) {
|
|
|
115
149
|
templateKey,
|
|
116
150
|
});
|
|
117
151
|
}
|
|
118
|
-
await
|
|
152
|
+
cloneStrategy = await cloneDirectoryAtomically({
|
|
153
|
+
sourcePath: templatePath,
|
|
154
|
+
targetPath: sessionPath,
|
|
155
|
+
copyDirectory: input.copyDirectory,
|
|
156
|
+
});
|
|
119
157
|
}
|
|
120
158
|
}
|
|
121
|
-
|
|
159
|
+
await touchCacheMetadata({
|
|
160
|
+
cacheRoot: resolveBwrapCacheRoot(runtimeContext.appRoot, options.cacheDir),
|
|
161
|
+
kind: "session",
|
|
162
|
+
id: basename(sessionPath),
|
|
163
|
+
tags,
|
|
164
|
+
cloneStrategy,
|
|
165
|
+
});
|
|
166
|
+
const session = await openRuntimeSession(sessionKey, sessionPath, runtimeContext.appRoot, tags);
|
|
122
167
|
return {
|
|
123
168
|
session,
|
|
124
169
|
useSessionFn: async (useOptions) => await useSession(session, useOptions),
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type BwrapCacheEntryKind = "session" | "template";
|
|
2
|
+
export type BwrapCloneStrategy = "empty" | "reflink" | "copy" | "existing";
|
|
3
|
+
export interface BwrapCacheMetadata {
|
|
4
|
+
readonly schemaVersion: 1;
|
|
5
|
+
readonly kind: BwrapCacheEntryKind;
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly createdAt: string;
|
|
8
|
+
readonly lastUsedAt: string;
|
|
9
|
+
readonly tags?: Readonly<Record<string, string>>;
|
|
10
|
+
readonly cloneStrategy?: BwrapCloneStrategy;
|
|
11
|
+
readonly templateRevision?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface BwrapCacheEntry extends BwrapCacheMetadata {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly sizeBytes: number;
|
|
16
|
+
readonly active: boolean;
|
|
17
|
+
readonly metadataPresent: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface BwrapCacheLocation {
|
|
20
|
+
readonly appRoot: string;
|
|
21
|
+
readonly cacheDir?: string | null;
|
|
22
|
+
}
|
|
23
|
+
export interface BwrapCachePrunePolicy {
|
|
24
|
+
readonly maxAgeMs?: number;
|
|
25
|
+
readonly maxEntries?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface BwrapCachePruneInput extends BwrapCacheLocation {
|
|
28
|
+
readonly dryRun?: boolean;
|
|
29
|
+
readonly sessions?: BwrapCachePrunePolicy;
|
|
30
|
+
readonly templates?: BwrapCachePrunePolicy;
|
|
31
|
+
readonly now?: Date;
|
|
32
|
+
}
|
|
33
|
+
export interface BwrapCachePruneResult {
|
|
34
|
+
readonly dryRun: boolean;
|
|
35
|
+
readonly candidates: readonly BwrapCacheEntry[];
|
|
36
|
+
readonly removed: readonly BwrapCacheEntry[];
|
|
37
|
+
readonly skippedActive: readonly BwrapCacheEntry[];
|
|
38
|
+
readonly retained: readonly BwrapCacheEntry[];
|
|
39
|
+
}
|
|
40
|
+
export interface BwrapCacheLease {
|
|
41
|
+
readonly sessionId: string;
|
|
42
|
+
readonly generationId: string;
|
|
43
|
+
readonly pid: number;
|
|
44
|
+
readonly createdAt: string;
|
|
45
|
+
readonly path: string;
|
|
46
|
+
}
|
|
47
|
+
export declare function registerActiveCachePath(path: string): () => void;
|
|
48
|
+
export declare function createBwrapCacheLease(input: {
|
|
49
|
+
cacheRoot: string;
|
|
50
|
+
sessionId: string;
|
|
51
|
+
}): Promise<{
|
|
52
|
+
lease: BwrapCacheLease;
|
|
53
|
+
release(): Promise<void>;
|
|
54
|
+
}>;
|
|
55
|
+
export declare function listBwrapCacheLeases(input: BwrapCacheLocation): Promise<BwrapCacheLease[]>;
|
|
56
|
+
export declare function listBwrapCache(input: BwrapCacheLocation): Promise<BwrapCacheEntry[]>;
|
|
57
|
+
export declare function pruneBwrapCache(input: BwrapCachePruneInput): Promise<BwrapCachePruneResult>;
|
|
58
|
+
export type BwrapDirectoryCopier = (source: string, target: string, options: {
|
|
59
|
+
recursive: true;
|
|
60
|
+
mode?: number;
|
|
61
|
+
}) => Promise<void>;
|
|
62
|
+
export declare function cloneDirectoryAtomically(input: {
|
|
63
|
+
sourcePath: string;
|
|
64
|
+
targetPath: string;
|
|
65
|
+
copyDirectory?: BwrapDirectoryCopier;
|
|
66
|
+
}): Promise<BwrapCloneStrategy>;
|
|
67
|
+
export declare function touchCacheMetadata(input: {
|
|
68
|
+
cacheRoot: string;
|
|
69
|
+
kind: BwrapCacheEntryKind;
|
|
70
|
+
id: string;
|
|
71
|
+
tags?: Readonly<Record<string, string>>;
|
|
72
|
+
cloneStrategy?: BwrapCloneStrategy;
|
|
73
|
+
templateRevision?: string | null;
|
|
74
|
+
now?: Date;
|
|
75
|
+
}): Promise<BwrapCacheMetadata>;
|
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
|
@@ -2,7 +2,10 @@ import type { SandboxBackend } from "eve/sandbox";
|
|
|
2
2
|
import type { BwrapSandboxCreateOptions, BwrapSandboxUseOptions } from "./options.js";
|
|
3
3
|
export { BWRAP_BACKEND_NAME, createBwrapSandboxBackend, type CreateBwrapSandboxBackendInput, } from "./backend.js";
|
|
4
4
|
export type { BwrapNetworkPolicy, BwrapSandboxCreateOptions, BwrapSandboxUseOptions, } from "./options.js";
|
|
5
|
-
export {
|
|
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
|
/**
|
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,3 +1,4 @@
|
|
|
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";
|
|
3
4
|
/** Options lifecycle hooks can apply when they open a template or live Session. */
|
|
@@ -7,6 +8,10 @@ export interface BwrapSandboxUseOptions {
|
|
|
7
8
|
}
|
|
8
9
|
/** Commands executed through `run()` are bounded by default; use `spawn()` for daemons. */
|
|
9
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;
|
|
10
15
|
/** Options accepted by `bwrap(opts)`. */
|
|
11
16
|
export interface BwrapSandboxCreateOptions {
|
|
12
17
|
/** Environment variables set for every command the backend runs. */
|
|
@@ -36,6 +41,12 @@ export interface BwrapSandboxCreateOptions {
|
|
|
36
41
|
* long-running `spawn()` API.
|
|
37
42
|
*/
|
|
38
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;
|
|
39
50
|
}
|
|
40
51
|
/** Fully-defaulted options consumed by the backend implementation. */
|
|
41
52
|
export interface ResolvedBwrapSandboxOptions {
|
|
@@ -46,6 +57,9 @@ export interface ResolvedBwrapSandboxOptions {
|
|
|
46
57
|
readonly cacheDir: string | null;
|
|
47
58
|
readonly templateRevision: string | null;
|
|
48
59
|
readonly runTimeoutMs: number | null;
|
|
60
|
+
readonly maxConcurrentProcesses: number | null;
|
|
61
|
+
readonly maxOutputBytes: number | null;
|
|
62
|
+
readonly onEvent?: BwrapSandboxEventSink;
|
|
49
63
|
}
|
|
50
64
|
export declare function resolveBwrapSandboxOptions(options?: BwrapSandboxCreateOptions): ResolvedBwrapSandboxOptions;
|
|
51
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;
|
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 };
|