@evelandhq/sandbox-bwrap 0.1.0 → 0.1.2
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 +25 -14
- package/dist/backend.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/options.d.ts +9 -0
- package/dist/options.js +13 -0
- package/dist/session.js +26 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -54,18 +54,24 @@ This package requires `eve` `>=0.27.0 <1.0.0`.
|
|
|
54
54
|
The range is deliberately wide. eve's 0.x releases use caret-incompatible minor bumps,
|
|
55
55
|
so a package that pins a narrow window has to republish for every eve minor — which is
|
|
56
56
|
churn for consumers, not safety, when the surface actually consumed is one small
|
|
57
|
-
interface (`SandboxBackend` from `eve/sandbox`) that
|
|
58
|
-
|
|
57
|
+
interface (`SandboxBackend` from `eve/sandbox`) that changes rarely. Rather than
|
|
58
|
+
re-declaring the window, CI keeps the claim honest from both ends:
|
|
59
59
|
`src/eve-compatibility.test.ts` typechecks the backend against the range's exact floor
|
|
60
60
|
(0.27.13) and the newest verified release on every run, and a scheduled workflow re-runs
|
|
61
61
|
the suite against `eve@latest` so a breaking eve minor shows up as a red build here
|
|
62
|
-
instead of a bug report from your deployment.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
instead of a bug report from your deployment. That is not theoretical: eve 0.32.0 added a
|
|
63
|
+
required `stop()` to the backend handle. This package implements it; 0.1.0 does not, and
|
|
64
|
+
pairing that release with eve `>=0.32.0` resolves cleanly and then fails at runtime the
|
|
65
|
+
first time authored code calls `ctx.getSandbox().stop()`.
|
|
66
|
+
|
|
67
|
+
The backend implements both handle lifecycle methods by killing every process the session
|
|
68
|
+
has spawned that has not yet exited: `shutdown()`, which eve calls at server teardown and
|
|
69
|
+
which requires that nothing be left running afterwards, and `stop()`, which authored code
|
|
70
|
+
triggers mid-run through `ctx.getSandbox().stop()`. Backends with provider-side compute
|
|
71
|
+
distinguish the two — a container to pause, a VM to snapshot; bwrap has no such resource,
|
|
72
|
+
because the processes are the compute. Neither method touches the session's workspace
|
|
73
|
+
directory — it is durable state, and remains available when the session reattaches or the
|
|
74
|
+
next callback reopens it.
|
|
69
75
|
|
|
70
76
|
### Options
|
|
71
77
|
|
|
@@ -77,6 +83,7 @@ reattaches.
|
|
|
77
83
|
| `bwrapPath` | `"bwrap"` | bwrap executable to invoke. |
|
|
78
84
|
| `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`. |
|
|
79
85
|
| `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`. |
|
|
86
|
+
| `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. |
|
|
80
87
|
|
|
81
88
|
## How it works
|
|
82
89
|
|
|
@@ -92,6 +99,9 @@ reattaches.
|
|
|
92
99
|
- **run/spawn**: every command is one transient bwrap invocation —
|
|
93
100
|
read-only host rootfs, the session directory bound read-write at `/workspace`,
|
|
94
101
|
tmpfs `/tmp`, PID/IPC/UTS namespaces unshared, `--die-with-parent`.
|
|
102
|
+
`run()` also applies `runTimeoutMs` and kills the entire detached process group
|
|
103
|
+
when the deadline or caller AbortSignal fires; `spawn()` remains unbounded until
|
|
104
|
+
its holder calls `kill()`, the Session is stopped, or the backend shuts down.
|
|
95
105
|
- **File I/O** (`readTextFile`, `writeFile`, …): host-side operations on the session
|
|
96
106
|
directory; no subprocess. Writes outside `/workspace` are refused.
|
|
97
107
|
|
|
@@ -103,8 +113,8 @@ reattach when a session resumes. Each session key gets a directory that is reuse
|
|
|
103
113
|
the lifetime of the session; each template is cached per (template key, options hash), with
|
|
104
114
|
an optional release revision in the options hash,
|
|
105
115
|
and reused across sessions. This backend intentionally does not prune either — its
|
|
106
|
-
`shutdown()`
|
|
107
|
-
disk, so reattach is instant and stateless from the agent's perspective. On a long-lived
|
|
116
|
+
`stop()` and `shutdown()` methods only kill the session's live processes and leave the
|
|
117
|
+
workspace on disk, so reattach is instant and stateless from the agent's perspective. On a long-lived
|
|
108
118
|
host, this means the cache will grow with the number of durable sessions and unique
|
|
109
119
|
templates, consuming disk space indefinitely. On eveland deployments this cache lives at
|
|
110
120
|
`EVELAND_SANDBOX_CACHE_DIR` (one subdirectory per project), outside every release
|
|
@@ -139,9 +149,10 @@ Reclaiming space today requires manual intervention: identify which sessions are
|
|
|
139
149
|
(Firecracker/microsandbox) instead of hardening this backend further.
|
|
140
150
|
Under Eveland's local Docker runtime, "host filesystem" here means the outer Agent
|
|
141
151
|
container's filesystem, not the Docker host; no host root or Docker socket is mounted.
|
|
142
|
-
-
|
|
143
|
-
|
|
144
|
-
children too). The backend
|
|
152
|
+
- CPU, memory, and PID limits are inherited from whatever cgroup the agent runs in
|
|
153
|
+
(on eveland, the deployment's Docker container or systemd unit covers sandbox
|
|
154
|
+
children too). The backend adds only the `run()` wall-clock deadline; it does not
|
|
155
|
+
create a per-command cgroup or impose resource quotas on `spawn()`.
|
|
145
156
|
- Host-side write/remove calls (`writeFile`, `writeTextFile`, `writeBinaryFile`,
|
|
146
157
|
`removePath`) verify containment with a realpath-aware check
|
|
147
158
|
(`isWithinWorkspaceReal`): they resolve symlinks along the path and re-check that
|
package/dist/backend.js
CHANGED
|
@@ -118,6 +118,16 @@ export function createBwrapSandboxBackend(input = {}) {
|
|
|
118
118
|
async captureState() {
|
|
119
119
|
return { backendName: BWRAP_BACKEND_NAME, metadata: {}, sessionKey };
|
|
120
120
|
},
|
|
121
|
+
// eve (>=0.32) calls this when authored code runs
|
|
122
|
+
// `ctx.getSandbox().stop()` mid-run: stop the compute, keep the durable
|
|
123
|
+
// session. Backends with provider-side compute distinguish this from
|
|
124
|
+
// shutdown() — a container to pause, a VM to snapshot. bwrap has no such
|
|
125
|
+
// resource: the processes are the compute and the workspace directory is
|
|
126
|
+
// the session, so stopping is killing the processes, and the next
|
|
127
|
+
// create() reopens the same workspace.
|
|
128
|
+
async stop() {
|
|
129
|
+
await session.killAll();
|
|
130
|
+
},
|
|
121
131
|
// eve calls this when the server is shutting down: nothing may be left
|
|
122
132
|
// running afterwards. The workspace directory IS the durable state, so
|
|
123
133
|
// it stays on disk and the session reattaches on the next start.
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { SandboxBackend } from "eve/sandbox";
|
|
|
2
2
|
import type { BwrapSandboxCreateOptions } from "./options.js";
|
|
3
3
|
export { BWRAP_BACKEND_NAME, createBwrapSandboxBackend, type CreateBwrapSandboxBackendInput, } from "./backend.js";
|
|
4
4
|
export type { BwrapNetworkPolicy, BwrapSandboxCreateOptions } from "./options.js";
|
|
5
|
+
export { DEFAULT_RUN_TIMEOUT_MS } from "./options.js";
|
|
5
6
|
export { isBwrapAvailable } from "./process.js";
|
|
6
7
|
export type { ProcessRunner, SpawnedProcess } from "./process.js";
|
|
7
8
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createBwrapSandboxBackend } from "./backend.js";
|
|
2
2
|
export { BWRAP_BACKEND_NAME, createBwrapSandboxBackend, } from "./backend.js";
|
|
3
|
+
export { DEFAULT_RUN_TIMEOUT_MS } from "./options.js";
|
|
3
4
|
export { isBwrapAvailable } from "./process.js";
|
|
4
5
|
/**
|
|
5
6
|
* Creates the bubblewrap sandbox backend for `defineSandbox({ backend })`.
|
package/dist/options.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Coarse egress control, matching what eve's Docker backend supports. */
|
|
2
2
|
export type BwrapNetworkPolicy = "allow-all" | "deny-all";
|
|
3
|
+
/** Commands executed through `run()` are bounded by default; use `spawn()` for daemons. */
|
|
4
|
+
export declare const DEFAULT_RUN_TIMEOUT_MS = 600000;
|
|
3
5
|
/** Options accepted by `bwrap(opts)`. */
|
|
4
6
|
export interface BwrapSandboxCreateOptions {
|
|
5
7
|
/** Environment variables set for every command the backend runs. */
|
|
@@ -23,6 +25,12 @@ export interface BwrapSandboxCreateOptions {
|
|
|
23
25
|
* remain keyed solely by Eve's session key.
|
|
24
26
|
*/
|
|
25
27
|
readonly templateRevision?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Hard wall-clock limit for one `run()` command. Defaults to 10 minutes.
|
|
30
|
+
* Set to `null` to disable. This does not apply to the deliberately
|
|
31
|
+
* long-running `spawn()` API.
|
|
32
|
+
*/
|
|
33
|
+
readonly runTimeoutMs?: number | null;
|
|
26
34
|
}
|
|
27
35
|
/** Fully-defaulted options consumed by the backend implementation. */
|
|
28
36
|
export interface ResolvedBwrapSandboxOptions {
|
|
@@ -32,6 +40,7 @@ export interface ResolvedBwrapSandboxOptions {
|
|
|
32
40
|
readonly bwrapPath: string;
|
|
33
41
|
readonly cacheDir: string | null;
|
|
34
42
|
readonly templateRevision: string | null;
|
|
43
|
+
readonly runTimeoutMs: number | null;
|
|
35
44
|
}
|
|
36
45
|
export declare function resolveBwrapSandboxOptions(options?: BwrapSandboxCreateOptions): ResolvedBwrapSandboxOptions;
|
|
37
46
|
/**
|
package/dist/options.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
/** Commands executed through `run()` are bounded by default; use `spawn()` for daemons. */
|
|
3
|
+
export const DEFAULT_RUN_TIMEOUT_MS = 600_000;
|
|
4
|
+
function resolveRunTimeoutMs(value) {
|
|
5
|
+
if (value === null)
|
|
6
|
+
return null;
|
|
7
|
+
const resolved = value ?? DEFAULT_RUN_TIMEOUT_MS;
|
|
8
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
9
|
+
throw new Error("bwrap sandbox: runTimeoutMs must be a positive safe integer or null");
|
|
10
|
+
}
|
|
11
|
+
return resolved;
|
|
12
|
+
}
|
|
2
13
|
export function resolveBwrapSandboxOptions(options = {}) {
|
|
3
14
|
return {
|
|
4
15
|
env: options.env ?? {},
|
|
@@ -7,6 +18,7 @@ export function resolveBwrapSandboxOptions(options = {}) {
|
|
|
7
18
|
bwrapPath: options.bwrapPath ?? "bwrap",
|
|
8
19
|
cacheDir: options.cacheDir ?? null,
|
|
9
20
|
templateRevision: options.templateRevision ?? null,
|
|
21
|
+
runTimeoutMs: resolveRunTimeoutMs(options.runTimeoutMs),
|
|
10
22
|
};
|
|
11
23
|
}
|
|
12
24
|
/**
|
|
@@ -21,6 +33,7 @@ export function createBwrapOptionsHash(options) {
|
|
|
21
33
|
env: Object.fromEntries(Object.entries(options.env).sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
22
34
|
hidePaths: [...options.hidePaths],
|
|
23
35
|
networkPolicy: options.networkPolicy,
|
|
36
|
+
runTimeoutMs: options.runTimeoutMs,
|
|
24
37
|
templateRevision: options.templateRevision,
|
|
25
38
|
});
|
|
26
39
|
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
package/dist/session.js
CHANGED
|
@@ -34,6 +34,19 @@ function sliceLines(text, startLine, endLine) {
|
|
|
34
34
|
const lines = text.split("\n");
|
|
35
35
|
return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join("\n");
|
|
36
36
|
}
|
|
37
|
+
function createRunAbortSignal(callerSignal, timeoutMs) {
|
|
38
|
+
if (timeoutMs === null)
|
|
39
|
+
return { signal: callerSignal, clear() { } };
|
|
40
|
+
const timeout = new AbortController();
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
timeout.abort(new Error(`bwrap sandbox: run timed out after ${timeoutMs} ms`));
|
|
43
|
+
}, timeoutMs);
|
|
44
|
+
timer.unref?.();
|
|
45
|
+
return {
|
|
46
|
+
signal: callerSignal ? AbortSignal.any([callerSignal, timeout.signal]) : timeout.signal,
|
|
47
|
+
clear: () => clearTimeout(timer),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
37
50
|
export function createBwrapSession(input) {
|
|
38
51
|
const { id, workspaceDir, appRoot, runner, options } = input;
|
|
39
52
|
let networkPolicy = options.networkPolicy;
|
|
@@ -114,13 +127,19 @@ export function createBwrapSession(input) {
|
|
|
114
127
|
return await spawnProcess(spawnOptions);
|
|
115
128
|
},
|
|
116
129
|
async run(runOptions) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
const runAbort = createRunAbortSignal(runOptions.abortSignal, options.runTimeoutMs);
|
|
131
|
+
try {
|
|
132
|
+
const proc = await spawnProcess({ ...runOptions, abortSignal: runAbort.signal });
|
|
133
|
+
const [stdout, stderr, { exitCode }] = await Promise.all([
|
|
134
|
+
collectStream(proc.stdout),
|
|
135
|
+
collectStream(proc.stderr),
|
|
136
|
+
proc.wait(),
|
|
137
|
+
]);
|
|
138
|
+
return { exitCode, stdout, stderr };
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
runAbort.clear();
|
|
142
|
+
}
|
|
124
143
|
},
|
|
125
144
|
async setNetworkPolicy(policy) {
|
|
126
145
|
if (policy !== "allow-all" && policy !== "deny-all") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evelandhq/sandbox-bwrap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "bubblewrap SandboxBackend for eve agents — real exec sandboxing without Docker or KVM",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/node": "^26.0.1",
|
|
42
42
|
"ai": "^7.0.44",
|
|
43
|
-
"eve": "0.
|
|
43
|
+
"eve": "0.37.0",
|
|
44
44
|
"eve-floor": "npm:eve@0.27.13",
|
|
45
45
|
"oxfmt": "0.58.0",
|
|
46
46
|
"oxlint": "1.73.0",
|