@evelandhq/sandbox-bwrap 0.1.1 → 0.1.3
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 +23 -11
- package/dist/backend.d.ts +2 -2
- package/dist/backend.js +10 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -0
- package/dist/options.d.ts +14 -0
- package/dist/options.js +13 -0
- package/dist/session.js +26 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -21,11 +21,12 @@ the sandbox module into the release directory at build time — `agent/sandbox.j
|
|
|
21
21
|
agent, or `agent/sandbox/sandbox.js` when a sandbox folder exists, recursively for every
|
|
22
22
|
subagent — and vendors this package's built output beside it, so agent projects never declare
|
|
23
23
|
a deployment backend themselves. If a project shipped its own sandbox module, the build
|
|
24
|
-
|
|
25
|
-
`onSession()`
|
|
26
|
-
so Eve still seeds those files into each
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
wraps that definition and reports it in the build log: Eveland overrides only `backend`, while
|
|
25
|
+
authored `bootstrap()`, `onSession()`, `description`, and `revalidationKey` remain active. The
|
|
26
|
+
sibling `agent/sandbox/workspace/**` tree is preserved, so Eve still seeds those files into each
|
|
27
|
+
Session's `/workspace`. Each Eveland Release supplies a distinct template revision, so Sessions
|
|
28
|
+
created against a new Deployment see its updated seeds while existing durable Session workspaces
|
|
29
|
+
remain untouched. The systemd runtime invokes
|
|
29
30
|
bwrap as its unprivileged deployment user. The local Docker runtime installs bwrap inside the Agent
|
|
30
31
|
image and grants the outer container only the capabilities nested bwrap requires; the
|
|
31
32
|
Agent container still receives no Docker socket. Local `eve dev` is untouched — it never runs
|
|
@@ -83,12 +84,19 @@ next callback reopens it.
|
|
|
83
84
|
| `bwrapPath` | `"bwrap"` | bwrap executable to invoke. |
|
|
84
85
|
| `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`. |
|
|
85
86
|
| `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`. |
|
|
87
|
+
| `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. |
|
|
88
|
+
|
|
89
|
+
Both lifecycle callbacks may call `use({ networkPolicy: "allow-all" | "deny-all" })`. The
|
|
90
|
+
policy is applied before `use()` returns the template or live Session, so subsequent commands in
|
|
91
|
+
that callback use the requested network boundary. Calling `use()` without options keeps the
|
|
92
|
+
backend's configured policy.
|
|
86
93
|
|
|
87
94
|
## How it works
|
|
88
95
|
|
|
89
|
-
- **prewarm** (build time):
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
- **prewarm** (build time): resolves Eve's `$HOME/.agents/skills/**` seed paths to
|
|
97
|
+
`/workspace/.agents/skills/**`, writes every seed into a staging directory, then runs the
|
|
98
|
+
authored `bootstrap` inside bwrap so it can consume those canonical inputs, before atomically
|
|
99
|
+
renaming the result into
|
|
92
100
|
`<cacheDir>/templates/<hash>` (`<cacheDir>` defaults to
|
|
93
101
|
`<appRoot>/.eve/sandbox-cache/bwrap` when the `cacheDir` option is not set). Idempotent
|
|
94
102
|
per template key + options hash; `templateRevision` participates in that hash.
|
|
@@ -98,6 +106,9 @@ next callback reopens it.
|
|
|
98
106
|
- **run/spawn**: every command is one transient bwrap invocation —
|
|
99
107
|
read-only host rootfs, the session directory bound read-write at `/workspace`,
|
|
100
108
|
tmpfs `/tmp`, PID/IPC/UTS namespaces unshared, `--die-with-parent`.
|
|
109
|
+
`run()` also applies `runTimeoutMs` and kills the entire detached process group
|
|
110
|
+
when the deadline or caller AbortSignal fires; `spawn()` remains unbounded until
|
|
111
|
+
its holder calls `kill()`, the Session is stopped, or the backend shuts down.
|
|
101
112
|
- **File I/O** (`readTextFile`, `writeFile`, …): host-side operations on the session
|
|
102
113
|
directory; no subprocess. Writes outside `/workspace` are refused.
|
|
103
114
|
|
|
@@ -145,9 +156,10 @@ Reclaiming space today requires manual intervention: identify which sessions are
|
|
|
145
156
|
(Firecracker/microsandbox) instead of hardening this backend further.
|
|
146
157
|
Under Eveland's local Docker runtime, "host filesystem" here means the outer Agent
|
|
147
158
|
container's filesystem, not the Docker host; no host root or Docker socket is mounted.
|
|
148
|
-
-
|
|
149
|
-
|
|
150
|
-
children too). The backend
|
|
159
|
+
- CPU, memory, and PID limits are inherited from whatever cgroup the agent runs in
|
|
160
|
+
(on eveland, the deployment's Docker container or systemd unit covers sandbox
|
|
161
|
+
children too). The backend adds only the `run()` wall-clock deadline; it does not
|
|
162
|
+
create a per-command cgroup or impose resource quotas on `spawn()`.
|
|
151
163
|
- Host-side write/remove calls (`writeFile`, `writeTextFile`, `writeBinaryFile`,
|
|
152
164
|
`removePath`) verify containment with a realpath-aware check
|
|
153
165
|
(`isWithinWorkspaceReal`): they resolve symlinks along the path and re-check that
|
package/dist/backend.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
import type { ProcessRunner } from "./process.js";
|
|
4
4
|
/**
|
|
5
5
|
* Stable backend name. Participates in eve's template/session cache-key
|
|
@@ -11,4 +11,4 @@ export interface CreateBwrapSandboxBackendInput {
|
|
|
11
11
|
/** Injectable process launcher so backend logic is testable without bwrap. */
|
|
12
12
|
readonly runner?: ProcessRunner;
|
|
13
13
|
}
|
|
14
|
-
export declare function createBwrapSandboxBackend(input?: CreateBwrapSandboxBackendInput): SandboxBackend
|
|
14
|
+
export declare function createBwrapSandboxBackend(input?: CreateBwrapSandboxBackendInput): SandboxBackend<BwrapSandboxUseOptions, BwrapSandboxUseOptions>;
|
package/dist/backend.js
CHANGED
|
@@ -67,6 +67,12 @@ export function createBwrapSandboxBackend(input = {}) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
async function useSession(session, useOptions) {
|
|
71
|
+
if (useOptions?.networkPolicy !== undefined) {
|
|
72
|
+
await session.setNetworkPolicy(useOptions.networkPolicy);
|
|
73
|
+
}
|
|
74
|
+
return session;
|
|
75
|
+
}
|
|
70
76
|
return {
|
|
71
77
|
name: BWRAP_BACKEND_NAME,
|
|
72
78
|
async prewarm({ templateKey, bootstrap, seedFiles, log, runtimeContext }) {
|
|
@@ -79,9 +85,10 @@ export function createBwrapSandboxBackend(input = {}) {
|
|
|
79
85
|
await mkdir(stagingPath, { recursive: true });
|
|
80
86
|
try {
|
|
81
87
|
const session = openSession(templateKey, stagingPath, runtimeContext.appRoot);
|
|
82
|
-
if (bootstrap)
|
|
83
|
-
await bootstrap({ use: async () => session });
|
|
84
88
|
await writeSeedFiles(session, seedFiles);
|
|
89
|
+
if (bootstrap) {
|
|
90
|
+
await bootstrap({ use: async (useOptions) => await useSession(session, useOptions) });
|
|
91
|
+
}
|
|
85
92
|
await rename(stagingPath, templatePath);
|
|
86
93
|
}
|
|
87
94
|
catch (error) {
|
|
@@ -114,7 +121,7 @@ export function createBwrapSandboxBackend(input = {}) {
|
|
|
114
121
|
const session = openSession(sessionKey, sessionPath, runtimeContext.appRoot);
|
|
115
122
|
return {
|
|
116
123
|
session,
|
|
117
|
-
useSessionFn: async () => session,
|
|
124
|
+
useSessionFn: async (useOptions) => await useSession(session, useOptions),
|
|
118
125
|
async captureState() {
|
|
119
126
|
return { backendName: BWRAP_BACKEND_NAME, metadata: {}, sessionKey };
|
|
120
127
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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";
|
|
4
|
+
export type { BwrapNetworkPolicy, BwrapSandboxCreateOptions, BwrapSandboxUseOptions, } 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
|
/**
|
|
@@ -17,4 +18,4 @@ export type { ProcessRunner, SpawnedProcess } from "./process.js";
|
|
|
17
18
|
* });
|
|
18
19
|
* ```
|
|
19
20
|
*/
|
|
20
|
-
export declare function bwrap(options?: BwrapSandboxCreateOptions): SandboxBackend
|
|
21
|
+
export declare function bwrap(options?: BwrapSandboxCreateOptions): SandboxBackend<BwrapSandboxUseOptions, BwrapSandboxUseOptions>;
|
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,12 @@
|
|
|
1
1
|
/** Coarse egress control, matching what eve's Docker backend supports. */
|
|
2
2
|
export type BwrapNetworkPolicy = "allow-all" | "deny-all";
|
|
3
|
+
/** Options lifecycle hooks can apply when they open a template or live Session. */
|
|
4
|
+
export interface BwrapSandboxUseOptions {
|
|
5
|
+
/** Network policy used by subsequent commands in this lifecycle callback. */
|
|
6
|
+
readonly networkPolicy?: BwrapNetworkPolicy;
|
|
7
|
+
}
|
|
8
|
+
/** Commands executed through `run()` are bounded by default; use `spawn()` for daemons. */
|
|
9
|
+
export declare const DEFAULT_RUN_TIMEOUT_MS = 600000;
|
|
3
10
|
/** Options accepted by `bwrap(opts)`. */
|
|
4
11
|
export interface BwrapSandboxCreateOptions {
|
|
5
12
|
/** Environment variables set for every command the backend runs. */
|
|
@@ -23,6 +30,12 @@ export interface BwrapSandboxCreateOptions {
|
|
|
23
30
|
* remain keyed solely by Eve's session key.
|
|
24
31
|
*/
|
|
25
32
|
readonly templateRevision?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Hard wall-clock limit for one `run()` command. Defaults to 10 minutes.
|
|
35
|
+
* Set to `null` to disable. This does not apply to the deliberately
|
|
36
|
+
* long-running `spawn()` API.
|
|
37
|
+
*/
|
|
38
|
+
readonly runTimeoutMs?: number | null;
|
|
26
39
|
}
|
|
27
40
|
/** Fully-defaulted options consumed by the backend implementation. */
|
|
28
41
|
export interface ResolvedBwrapSandboxOptions {
|
|
@@ -32,6 +45,7 @@ export interface ResolvedBwrapSandboxOptions {
|
|
|
32
45
|
readonly bwrapPath: string;
|
|
33
46
|
readonly cacheDir: string | null;
|
|
34
47
|
readonly templateRevision: string | null;
|
|
48
|
+
readonly runTimeoutMs: number | null;
|
|
35
49
|
}
|
|
36
50
|
export declare function resolveBwrapSandboxOptions(options?: BwrapSandboxCreateOptions): ResolvedBwrapSandboxOptions;
|
|
37
51
|
/**
|
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.3",
|
|
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",
|