@douglas-agent/sandbank-boxlite 0.6.1

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 ADDED
@@ -0,0 +1,120 @@
1
+ # @douglas-agent/sandbank-boxlite
2
+
3
+ > BoxLite bare-metal micro-VM sandbox adapter for [Sandbank](../../README.md).
4
+
5
+ BoxLite provides lightweight micro-VMs using libkrun (Hypervisor.framework on macOS, KVM on Linux). This adapter supports two modes of operation:
6
+
7
+ - **Remote mode** — Connect to a [BoxRun](https://github.com/nicholasgasior/boxlite) REST API server
8
+ - **Local mode** — Run VMs directly on the local machine via the boxlite Python SDK
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pnpm add @douglas-agent/sandbank-core @douglas-agent/sandbank-boxlite
14
+ ```
15
+
16
+ For local mode, you also need the boxlite Python package:
17
+
18
+ ```bash
19
+ pip install boxlite
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ### Remote mode (BoxRun REST API)
25
+
26
+ ```typescript
27
+ import { createProvider } from '@douglas-agent/sandbank-core'
28
+ import { BoxLiteAdapter } from '@douglas-agent/sandbank-boxlite'
29
+
30
+ const provider = createProvider(
31
+ new BoxLiteAdapter({
32
+ apiUrl: 'http://localhost:9090',
33
+ apiToken: process.env.BOXLITE_API_TOKEN,
34
+ prefix: 'default', // multi-tenant prefix (optional)
35
+ })
36
+ )
37
+
38
+ const sandbox = await provider.create({
39
+ image: 'ubuntu:24.04',
40
+ resources: { cpu: 2, memory: 1024 },
41
+ })
42
+
43
+ const { stdout } = await sandbox.exec('uname -a')
44
+ await provider.destroy(sandbox.id)
45
+ ```
46
+
47
+ ### Local mode (Python SDK)
48
+
49
+ ```typescript
50
+ import { createProvider } from '@douglas-agent/sandbank-core'
51
+ import { BoxLiteAdapter } from '@douglas-agent/sandbank-boxlite'
52
+
53
+ const provider = createProvider(
54
+ new BoxLiteAdapter({
55
+ mode: 'local',
56
+ pythonPath: '/usr/bin/python3', // optional, defaults to 'python3'
57
+ boxliteHome: '~/.boxlite', // optional
58
+ })
59
+ )
60
+
61
+ const sandbox = await provider.create({ image: 'ubuntu:24.04' })
62
+ const { stdout } = await sandbox.exec('echo hello')
63
+ await provider.destroy(sandbox.id)
64
+ ```
65
+
66
+ ### Local OCI rootfs (skip registry pull)
67
+
68
+ If `image` is an absolute path, it is treated as a local OCI layout directory:
69
+
70
+ ```typescript
71
+ // Export image: skopeo copy docker-daemon:myimage:latest oci:~/.boxlite/myimage-oci:latest
72
+ const sandbox = await provider.create({ image: '/Users/you/.boxlite/myimage-oci' })
73
+ ```
74
+
75
+ ### OAuth2 authentication (remote mode)
76
+
77
+ ```typescript
78
+ new BoxLiteAdapter({
79
+ apiUrl: 'http://boxrun.example.com:9090',
80
+ clientId: process.env.BOXLITE_CLIENT_ID,
81
+ clientSecret: process.env.BOXLITE_CLIENT_SECRET,
82
+ })
83
+ ```
84
+
85
+ ## Capabilities
86
+
87
+ | Capability | Remote | Local |
88
+ |------------|:------:|:-----:|
89
+ | `exec.stream` | ✅ | ✅ |
90
+ | `terminal` | ✅ | ✅ |
91
+ | `sleep` | ✅ | ✅ |
92
+ | `port.expose` | ✅ | ✅ |
93
+ | `snapshot` | ✅ | — |
94
+
95
+ ## Characteristics
96
+
97
+ - **Runtime:** Micro-VM (libkrun)
98
+ - **Cold start:** ~3-5s
99
+ - **File I/O:** tar archive upload/download
100
+ - **Hypervisor:** Hypervisor.framework (macOS) / KVM (Linux)
101
+ - **Local dependency:** `boxlite` Python package (local mode only)
102
+
103
+ ## Architecture
104
+
105
+ ```
106
+ ┌─────────────────────────────────────┐
107
+ │ BoxLiteAdapter │
108
+ │ mode: 'remote' | 'local' │
109
+ ├──────────────┬──────────────────────┤
110
+ │ REST Client │ Local Client │
111
+ │ (fetch) │ (Python subprocess) │
112
+ ├──────────────┼──────────────────────┤
113
+ │ BoxRun API │ boxlite Python SDK │
114
+ │ (HTTP/JSON) │ (JSON-line bridge) │
115
+ └──────────────┴──────────────────────┘
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,19 @@
1
+ import type { AdapterSandbox, Capability, CreateConfig, ListFilter, SandboxAdapter, SandboxInfo } from '@douglas-agent/sandbank-core';
2
+ import type { BoxLiteAdapterConfig, BoxLiteClient } from './types.js';
3
+ export declare class BoxLiteAdapter implements SandboxAdapter {
4
+ readonly name = "boxlite";
5
+ readonly capabilities: ReadonlySet<Capability>;
6
+ private readonly client;
7
+ private readonly config;
8
+ private readonly host;
9
+ private readonly portMaps;
10
+ constructor(config: BoxLiteAdapterConfig);
11
+ createSandbox(config: CreateConfig): Promise<AdapterSandbox>;
12
+ getSandbox(id: string): Promise<AdapterSandbox>;
13
+ listSandboxes(filter?: ListFilter): Promise<SandboxInfo[]>;
14
+ destroySandbox(id: string): Promise<void>;
15
+ getClient(): BoxLiteClient;
16
+ /** Dispose the adapter and clean up resources (e.g. Python bridge process) */
17
+ dispose(): Promise<void>;
18
+ }
19
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EACV,YAAY,EAGZ,UAAU,EACV,cAAc,EACd,WAAW,EAIZ,MAAM,8BAA8B,CAAA;AAIrC,OAAO,KAAK,EAAE,oBAAoB,EAAc,aAAa,EAAwB,MAAM,YAAY,CAAA;AAoKvG,qBAAa,cAAe,YAAW,cAAc;IACnD,QAAQ,CAAC,IAAI,aAAY;IACzB,QAAQ,CAAC,YAAY,EAAE,WAAW,CAAC,UAAU,CAAC,CAAA;IAE9C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsB;IAC7C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAQ;IAC7B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyC;gBAEtD,MAAM,EAAE,oBAAoB;IASlC,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC;IAmD5D,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAW/C,aAAa,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IA0B1D,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,SAAS,IAAI,aAAa;IAI1B,8EAA8E;IACxE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAG/B"}
@@ -0,0 +1,259 @@
1
+ import { SandboxNotFoundError, ProviderError } from '@douglas-agent/sandbank-core';
2
+ import { createBoxLiteRestClient } from './client.js';
3
+ import { createBoxLiteLocalClient } from './local-client.js';
4
+ /** Map BoxLite box status to Sandbank SandboxState */
5
+ function mapState(status) {
6
+ switch (status) {
7
+ case 'configured':
8
+ return 'creating';
9
+ case 'running':
10
+ return 'running';
11
+ case 'stopping':
12
+ case 'stopped':
13
+ case 'paused':
14
+ return 'stopped';
15
+ default:
16
+ return 'error';
17
+ }
18
+ }
19
+ /** Resolve the host used for port exposure and terminal URLs */
20
+ function resolveHost(config) {
21
+ if (config.mode === 'local')
22
+ return '127.0.0.1';
23
+ try {
24
+ return new URL(config.apiUrl).hostname;
25
+ }
26
+ catch {
27
+ return config.apiUrl;
28
+ }
29
+ }
30
+ /** Wrap a BoxLite box into an AdapterSandbox */
31
+ function wrapBox(box, client, host, portMappings) {
32
+ return {
33
+ get id() { return box.id; },
34
+ get state() { return mapState(box.status); },
35
+ get createdAt() { return box.created_at; },
36
+ async exec(command, options) {
37
+ const result = await client.exec(box.id, {
38
+ cmd: ['bash', '-c', command],
39
+ working_dir: options?.cwd,
40
+ timeout_seconds: options?.timeout ? Math.ceil(options.timeout / 1000) : undefined,
41
+ });
42
+ return {
43
+ exitCode: result.exitCode,
44
+ stdout: result.stdout,
45
+ stderr: result.stderr,
46
+ };
47
+ },
48
+ async execStream(command, options) {
49
+ return client.execStream(box.id, {
50
+ cmd: ['bash', '-c', command],
51
+ working_dir: options?.cwd,
52
+ timeout_seconds: options?.timeout ? Math.ceil(options.timeout / 1000) : undefined,
53
+ });
54
+ },
55
+ async uploadArchive(archive, destDir) {
56
+ let data;
57
+ if (archive instanceof Uint8Array) {
58
+ data = archive;
59
+ }
60
+ else {
61
+ const reader = archive.getReader();
62
+ const chunks = [];
63
+ while (true) {
64
+ const { done, value } = await reader.read();
65
+ if (done)
66
+ break;
67
+ chunks.push(value);
68
+ }
69
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
70
+ data = new Uint8Array(totalLength);
71
+ let offset = 0;
72
+ for (const chunk of chunks) {
73
+ data.set(chunk, offset);
74
+ offset += chunk.length;
75
+ }
76
+ }
77
+ await client.uploadFiles(box.id, destDir ?? '/', data);
78
+ },
79
+ async downloadArchive(srcDir) {
80
+ return client.downloadFiles(box.id, srcDir ?? '/');
81
+ },
82
+ async sleep() {
83
+ await client.stopBox(box.id);
84
+ },
85
+ async wake() {
86
+ await client.startBox(box.id);
87
+ },
88
+ async createSnapshot(name) {
89
+ const snapshotName = name ?? `snap-${Date.now()}`;
90
+ await client.createSnapshot(box.id, snapshotName);
91
+ return { snapshotId: snapshotName };
92
+ },
93
+ async restoreSnapshot(snapshotId) {
94
+ await client.restoreSnapshot(box.id, snapshotId);
95
+ },
96
+ async exposePort(port) {
97
+ const hostPort = portMappings.get(port) ?? port;
98
+ return { url: `http://${host}:${hostPort}` };
99
+ },
100
+ async startTerminal(options) {
101
+ const port = 7681;
102
+ const shell = options?.shell ?? '/bin/bash';
103
+ const ttydBase = 'https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd';
104
+ const check = await client.exec(box.id, { cmd: ['which', 'ttyd'] });
105
+ if (check.exitCode !== 0) {
106
+ await client.exec(box.id, {
107
+ cmd: ['bash', '-c',
108
+ `ARCH=$(uname -m); case "$ARCH" in aarch64|arm64) ARCH=aarch64;; x86_64) ARCH=x86_64;; *) echo "Unsupported arch: $ARCH" >&2; exit 1;; esac; `
109
+ + `TTYD_URL="${ttydBase}.$ARCH"; `
110
+ + `command -v curl > /dev/null && curl -sL "$TTYD_URL" -o /usr/local/bin/ttyd`
111
+ + ` || { command -v wget > /dev/null && wget -qO /usr/local/bin/ttyd "$TTYD_URL"; }`
112
+ + ` || { apt-get update -qq && apt-get install -y -qq wget > /dev/null && wget -qO /usr/local/bin/ttyd "$TTYD_URL"; }`,
113
+ ],
114
+ });
115
+ await client.exec(box.id, {
116
+ cmd: ['chmod', '+x', '/usr/local/bin/ttyd'],
117
+ });
118
+ }
119
+ await client.exec(box.id, {
120
+ cmd: ['bash', '-c', `nohup ttyd -W -p ${port} '${shell.replace(/'/g, "'\\''")}' > /dev/null 2>&1 &`],
121
+ });
122
+ await client.exec(box.id, {
123
+ cmd: ['bash', '-c', `for i in $(seq 1 20); do pgrep -x ttyd > /dev/null && break || sleep 0.5; done`],
124
+ });
125
+ const hostPort = portMappings.get(port) ?? port;
126
+ return {
127
+ url: `ws://${host}:${hostPort}/ws`,
128
+ port,
129
+ };
130
+ },
131
+ };
132
+ }
133
+ /** Check if an error is a 404 "not found" */
134
+ function isNotFound(err) {
135
+ const msg = err instanceof Error ? err.message : String(err);
136
+ return msg.includes('404') || msg.includes('not found') || msg.includes('Not Found');
137
+ }
138
+ /** Create the appropriate client based on config mode */
139
+ function createClient(config) {
140
+ if (config.mode === 'local') {
141
+ return createBoxLiteLocalClient(config);
142
+ }
143
+ return createBoxLiteRestClient(config);
144
+ }
145
+ export class BoxLiteAdapter {
146
+ name = 'boxlite';
147
+ capabilities;
148
+ client;
149
+ config;
150
+ host;
151
+ portMaps = new Map();
152
+ constructor(config) {
153
+ this.config = config;
154
+ this.host = resolveHost(config);
155
+ this.client = createClient(config);
156
+ const caps = ['exec.stream', 'terminal', 'sleep', 'port.expose', 'snapshot'];
157
+ this.capabilities = new Set(caps);
158
+ }
159
+ async createSandbox(config) {
160
+ try {
161
+ // If image looks like an absolute path, treat it as a local OCI rootfs
162
+ const image = config.image ?? 'ubuntu:24.04';
163
+ const isLocalPath = image.startsWith('/');
164
+ const user = typeof config.user === 'string' ? config.user : config.user?.name;
165
+ const box = await this.client.createBox({
166
+ ...(isLocalPath ? { rootfs_path: image } : { image }),
167
+ cpu: config.resources?.cpu,
168
+ memory_mb: config.resources?.memory,
169
+ disk_size_gb: config.resources?.disk,
170
+ env: config.env,
171
+ auto_remove: false,
172
+ ports: config.ports,
173
+ ...(user ? { user } : {}),
174
+ });
175
+ const portMap = new Map();
176
+ if (config.ports) {
177
+ for (const [hostPort, guestPort] of config.ports) {
178
+ portMap.set(guestPort, hostPort);
179
+ }
180
+ }
181
+ this.portMaps.set(box.id, portMap);
182
+ if (box.status === 'configured' || box.status === 'stopped') {
183
+ await this.client.startBox(box.id);
184
+ }
185
+ const timeoutSec = config.timeout ?? 30;
186
+ const maxAttempts = Math.max(1, timeoutSec);
187
+ let current = box;
188
+ for (let i = 0; i < maxAttempts; i++) {
189
+ current = await this.client.getBox(box.id);
190
+ if (current.status === 'running')
191
+ break;
192
+ await new Promise(r => setTimeout(r, 1000));
193
+ }
194
+ if (current.status !== 'running') {
195
+ await this.client.deleteBox(box.id, true).catch(() => { });
196
+ this.portMaps.delete(box.id);
197
+ throw new ProviderError('boxlite', new Error(`Sandbox failed to start within ${timeoutSec}s (status: ${current.status})`));
198
+ }
199
+ return wrapBox(current, this.client, this.host, portMap);
200
+ }
201
+ catch (err) {
202
+ if (err instanceof ProviderError)
203
+ throw err;
204
+ throw new ProviderError('boxlite', err);
205
+ }
206
+ }
207
+ async getSandbox(id) {
208
+ try {
209
+ const box = await this.client.getBox(id);
210
+ const portMap = this.portMaps.get(id) ?? new Map();
211
+ return wrapBox(box, this.client, this.host, portMap);
212
+ }
213
+ catch (err) {
214
+ if (isNotFound(err))
215
+ throw new SandboxNotFoundError('boxlite', id);
216
+ throw new ProviderError('boxlite', err, id);
217
+ }
218
+ }
219
+ async listSandboxes(filter) {
220
+ try {
221
+ const boxes = await this.client.listBoxes();
222
+ let infos = boxes.map((b) => ({
223
+ id: b.id,
224
+ state: mapState(b.status),
225
+ createdAt: b.created_at,
226
+ image: b.image,
227
+ }));
228
+ if (filter?.state) {
229
+ const states = Array.isArray(filter.state) ? filter.state : [filter.state];
230
+ infos = infos.filter(s => states.includes(s.state));
231
+ }
232
+ if (filter?.limit) {
233
+ infos = infos.slice(0, filter.limit);
234
+ }
235
+ return infos;
236
+ }
237
+ catch (err) {
238
+ throw new ProviderError('boxlite', err);
239
+ }
240
+ }
241
+ async destroySandbox(id) {
242
+ try {
243
+ await this.client.deleteBox(id, true);
244
+ this.portMaps.delete(id);
245
+ }
246
+ catch (err) {
247
+ if (isNotFound(err))
248
+ return;
249
+ throw new ProviderError('boxlite', err, id);
250
+ }
251
+ }
252
+ getClient() {
253
+ return this.client;
254
+ }
255
+ /** Dispose the adapter and clean up resources (e.g. Python bridge process) */
256
+ async dispose() {
257
+ await this.client.dispose?.();
258
+ }
259
+ }
@@ -0,0 +1,7 @@
1
+ import type { BoxLiteClient, BoxLiteRemoteConfig } from './types.js';
2
+ /**
3
+ * Create a BoxLite REST client for communicating with a BoxRun REST API.
4
+ * Used in remote mode.
5
+ */
6
+ export declare function createBoxLiteRestClient(config: BoxLiteRemoteConfig): BoxLiteClient;
7
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,aAAa,EAIb,mBAAmB,EAGpB,MAAM,YAAY,CAAA;AAEnB;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,mBAAmB,GAAG,aAAa,CA4VlF"}