@douglas-agent/sandbank-cloudflare 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 ADDED
@@ -0,0 +1,53 @@
1
+ # @douglas-agent/sandbank-cloudflare
2
+
3
+ > Cloudflare Workers sandbox adapter for [Sandbank](../../README.md).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @douglas-agent/sandbank-core @douglas-agent/sandbank-cloudflare
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { createProvider } from '@douglas-agent/sandbank-core'
15
+ import { CloudflareAdapter } from '@douglas-agent/sandbank-cloudflare'
16
+
17
+ const adapter = new CloudflareAdapter({
18
+ namespace: env.SANDBOX, // DurableObject binding
19
+ hostname: 'myapp.dev',
20
+ sleepAfter: '30m',
21
+ storage: { // enables volumes capability
22
+ endpoint: 'https://xxx.r2.cloudflarestorage.com',
23
+ provider: 'r2',
24
+ },
25
+ })
26
+
27
+ const provider = createProvider(adapter)
28
+ const sandbox = await provider.create({ image: 'node:22' })
29
+ const { stdout } = await sandbox.exec('echo hello')
30
+ await provider.destroy(sandbox.id)
31
+ ```
32
+
33
+ ## Capabilities
34
+
35
+ | Capability | Supported |
36
+ |------------|:---------:|
37
+ | `exec.stream` | ✅ |
38
+ | `terminal` | ✅ |
39
+ | `port.expose` | ✅ |
40
+ | `snapshot` | ✅ |
41
+ | `volumes` | ✅ (with `storage` config) |
42
+
43
+ ## Characteristics
44
+
45
+ - **Runtime:** V8 isolate + container
46
+ - **Cold start:** ~1s
47
+ - **File I/O:** Native SDK
48
+ - **Region:** Global edge
49
+ - **Dependency:** `@cloudflare/sandbox`
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,39 @@
1
+ import type { AdapterSandbox, Capability, CreateConfig, ListFilter, SandboxAdapter, SandboxInfo, VolumeConfig, VolumeInfo } from '@douglas-agent/sandbank-core';
2
+ import { type Sandbox as CloudflareSandbox } from '@cloudflare/sandbox';
3
+ export interface CloudflareAdapterConfig {
4
+ /** DurableObjectNamespace<Sandbox> binding */
5
+ namespace: DurableObjectNamespace<CloudflareSandbox>;
6
+ /** Hostname for port exposure, e.g. 'myapp.dev' */
7
+ hostname: string;
8
+ /** Auto-sleep duration, e.g. '30m' (optional) */
9
+ sleepAfter?: string;
10
+ /** Keep the container alive indefinitely (prevents auto-sleep). Must be explicitly destroyed. */
11
+ keepAlive?: boolean;
12
+ /** R2/S3 storage config (enables 'volumes' capability) */
13
+ storage?: {
14
+ endpoint: string;
15
+ credentials?: {
16
+ accessKeyId: string;
17
+ secretAccessKey: string;
18
+ };
19
+ provider?: 'r2' | 's3' | 'gcs';
20
+ };
21
+ }
22
+ export declare class CloudflareAdapter implements SandboxAdapter {
23
+ readonly name = "cloudflare";
24
+ readonly capabilities: ReadonlySet<Capability>;
25
+ private readonly config;
26
+ private readonly sandboxes;
27
+ private readonly volumes;
28
+ private readonly snapshots;
29
+ constructor(config: CloudflareAdapterConfig);
30
+ private getSnapshotsFor;
31
+ createSandbox(config: CreateConfig): Promise<AdapterSandbox>;
32
+ getSandbox(id: string): Promise<AdapterSandbox>;
33
+ listSandboxes(filter?: ListFilter): Promise<SandboxInfo[]>;
34
+ destroySandbox(id: string): Promise<void>;
35
+ createVolume(config: VolumeConfig): Promise<VolumeInfo>;
36
+ deleteVolume(id: string): Promise<void>;
37
+ listVolumes(): Promise<VolumeInfo[]>;
38
+ }
39
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EACV,YAAY,EAGZ,UAAU,EACV,cAAc,EACd,WAAW,EAIX,YAAY,EACZ,UAAU,EACX,MAAM,8BAA8B,CAAA;AAErC,OAAO,EAAc,KAAK,OAAO,IAAI,iBAAiB,EAAwB,MAAM,qBAAqB,CAAA;AAIzG,MAAM,WAAW,uBAAuB;IACtC,8CAA8C;IAC9C,SAAS,EAAE,sBAAsB,CAAC,iBAAiB,CAAC,CAAA;IACpD,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iGAAiG;IACjG,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,0DAA0D;IAC1D,OAAO,CAAC,EAAE;QACR,QAAQ,EAAE,MAAM,CAAA;QAChB,WAAW,CAAC,EAAE;YAAE,WAAW,EAAE,MAAM,CAAC;YAAC,eAAe,EAAE,MAAM,CAAA;SAAE,CAAA;QAC9D,QAAQ,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,KAAK,CAAA;KAC/B,CAAA;CACF;AAkKD,qBAAa,iBAAkB,YAAW,cAAc;IACtD,QAAQ,CAAC,IAAI,gBAAe;IAC5B,QAAQ,CAAC,YAAY,EAAE,WAAW,CAAC,UAAU,CAAC,CAAA;IAE9C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAyB;IAChD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmC;IAC7D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAkC;IAC1D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkD;gBAEhE,MAAM,EAAE,uBAAuB;IAU3C,OAAO,CAAC,eAAe;IASjB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC;IA+C5D,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAmB/C,aAAa,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IA0B1D,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBzC,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,UAAU,CAAC;IAYvD,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKvC,WAAW,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;CAQ3C"}
@@ -0,0 +1,258 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { SandboxNotFoundError, ProviderError } from '@douglas-agent/sandbank-core';
3
+ import { getSandbox } from '@cloudflare/sandbox';
4
+ // --- Retry helper ---
5
+ const CONTAINER_NOT_READY_RETRIES = 3;
6
+ const CONTAINER_NOT_READY_DELAY = 2000;
7
+ function isContainerNotReady(err) {
8
+ const msg = err instanceof Error ? err.message : String(err);
9
+ return msg.includes('CONTAINER_NOT_READY') || msg.includes('container not ready');
10
+ }
11
+ async function withRetry(fn) {
12
+ let lastError;
13
+ for (let i = 0; i < CONTAINER_NOT_READY_RETRIES; i++) {
14
+ try {
15
+ return await fn();
16
+ }
17
+ catch (err) {
18
+ lastError = err;
19
+ if (!isContainerNotReady(err) || i === CONTAINER_NOT_READY_RETRIES - 1)
20
+ throw err;
21
+ await new Promise(r => setTimeout(r, CONTAINER_NOT_READY_DELAY));
22
+ }
23
+ }
24
+ throw lastError;
25
+ }
26
+ // --- Sandbox wrapper ---
27
+ function wrapCloudflareSandbox(id, sandbox, state, createdAt, hostname, sandboxSnapshots) {
28
+ return {
29
+ id,
30
+ state,
31
+ createdAt,
32
+ async exec(command, options) {
33
+ const result = await withRetry(() => sandbox.exec(command, {
34
+ timeout: options?.timeout,
35
+ cwd: options?.cwd,
36
+ }));
37
+ return {
38
+ exitCode: result.exitCode ?? (result.success ? 0 : 1),
39
+ stdout: result.stdout ?? '',
40
+ stderr: result.stderr ?? '',
41
+ };
42
+ },
43
+ async writeFile(path, content) {
44
+ if (typeof content === 'string') {
45
+ await sandbox.writeFile(path, content, { encoding: 'utf-8' });
46
+ }
47
+ else {
48
+ // CF SDK writeFile only accepts string; encode binary as base64
49
+ let binary = '';
50
+ for (let i = 0; i < content.length; i++) {
51
+ binary += String.fromCharCode(content[i]);
52
+ }
53
+ await sandbox.writeFile(path, btoa(binary), { encoding: 'base64' });
54
+ }
55
+ },
56
+ async readFile(path) {
57
+ const result = await sandbox.readFile(path, { encoding: 'base64' });
58
+ const b64 = typeof result === 'string' ? result : result.content;
59
+ const binary = atob(b64);
60
+ const bytes = new Uint8Array(binary.length);
61
+ for (let i = 0; i < binary.length; i++) {
62
+ bytes[i] = binary.charCodeAt(i);
63
+ }
64
+ return bytes;
65
+ },
66
+ async execStream(command) {
67
+ return sandbox.execStream(command);
68
+ },
69
+ async exposePort(port) {
70
+ if (port === 3000) {
71
+ throw new Error('Port 3000 is reserved by the Cloudflare sandbox control plane. Use a different port (1024-65535, excluding 3000).');
72
+ }
73
+ const result = await sandbox.exposePort(port, { hostname });
74
+ return { url: result.url };
75
+ },
76
+ async createSnapshot(name) {
77
+ const backup = await sandbox.createBackup({ dir: '/', name: name ?? undefined });
78
+ const snapshotId = name ?? `snap-${crypto.randomUUID().slice(0, 8)}`;
79
+ sandboxSnapshots.set(snapshotId, backup);
80
+ return { snapshotId };
81
+ },
82
+ async restoreSnapshot(snapshotId) {
83
+ const backup = sandboxSnapshots.get(snapshotId);
84
+ if (!backup) {
85
+ throw new SandboxNotFoundError('cloudflare', snapshotId);
86
+ }
87
+ await sandbox.restoreBackup(backup);
88
+ },
89
+ async startTerminal(options) {
90
+ const port = 7681;
91
+ const shell = options?.shell ?? '/bin/bash';
92
+ const ttydBase = 'https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd';
93
+ // 1. Ensure ttyd is available (use wget fallback since curl may not be installed)
94
+ const check = await withRetry(() => sandbox.exec('which ttyd'));
95
+ if ((check.exitCode ?? (check.success ? 0 : 1)) !== 0) {
96
+ await withRetry(() => sandbox.exec(`ARCH=$(uname -m); case "$ARCH" in aarch64|arm64) ARCH=aarch64;; x86_64) ARCH=x86_64;; *) echo "Unsupported arch: $ARCH" >&2; exit 1;; esac; `
97
+ + `TTYD_URL="${ttydBase}.$ARCH"; `
98
+ + `command -v curl > /dev/null && curl -sL "$TTYD_URL" -o /usr/local/bin/ttyd`
99
+ + ` || { command -v wget > /dev/null && wget -qO /usr/local/bin/ttyd "$TTYD_URL"; }`
100
+ + ` || { apt-get update -qq && apt-get install -y -qq wget > /dev/null && wget -qO /usr/local/bin/ttyd "$TTYD_URL"; }`));
101
+ await withRetry(() => sandbox.exec('chmod +x /usr/local/bin/ttyd'));
102
+ }
103
+ // 2. Start ttyd in background (-W enables write)
104
+ await withRetry(() => sandbox.exec(`nohup ttyd -W -p ${port} '${shell.replace(/'/g, "'\\''")}' > /dev/null 2>&1 &`));
105
+ // 3. Wait for ttyd to be ready (check process is running)
106
+ await withRetry(() => sandbox.exec(`for i in $(seq 1 20); do pgrep -x ttyd > /dev/null && break || sleep 0.5; done`));
107
+ // 4. Expose port and return WebSocket URL
108
+ const exposed = await sandbox.exposePort(port, { hostname });
109
+ const url = exposed.url.replace(/\/$/, '') + '/ws';
110
+ return {
111
+ url,
112
+ port,
113
+ };
114
+ },
115
+ };
116
+ }
117
+ // --- Adapter ---
118
+ export class CloudflareAdapter {
119
+ name = 'cloudflare';
120
+ capabilities;
121
+ config;
122
+ sandboxes = new Map();
123
+ volumes = new Map();
124
+ snapshots = new Map();
125
+ constructor(config) {
126
+ this.config = config;
127
+ const caps = ['exec.stream', 'terminal', 'port.expose', 'snapshot'];
128
+ if (config.storage) {
129
+ caps.push('volumes');
130
+ }
131
+ this.capabilities = new Set(caps);
132
+ }
133
+ getSnapshotsFor(sandboxId) {
134
+ let map = this.snapshots.get(sandboxId);
135
+ if (!map) {
136
+ map = new Map();
137
+ this.snapshots.set(sandboxId, map);
138
+ }
139
+ return map;
140
+ }
141
+ async createSandbox(config) {
142
+ const id = `cf-${crypto.randomUUID().slice(0, 8)}`;
143
+ const externalId = crypto.randomUUID().slice(0, 8);
144
+ const createdAt = new Date().toISOString();
145
+ try {
146
+ const opts = {};
147
+ if (this.config.keepAlive) {
148
+ opts['keepAlive'] = true;
149
+ }
150
+ else if (this.config.sleepAfter) {
151
+ opts['sleepAfter'] = this.config.sleepAfter;
152
+ }
153
+ const sandbox = getSandbox(this.config.namespace, externalId, opts);
154
+ // Set environment variables if provided
155
+ if (config.env && Object.keys(config.env).length > 0) {
156
+ await withRetry(() => sandbox.setEnvVars(config.env));
157
+ }
158
+ // Mount volumes if configured
159
+ if (config.volumes && config.volumes.length > 0 && this.config.storage) {
160
+ for (const vol of config.volumes) {
161
+ await withRetry(() => sandbox.mountBucket(vol.id, vol.mountPath, {
162
+ endpoint: this.config.storage.endpoint,
163
+ provider: this.config.storage.provider ?? 'r2',
164
+ credentials: this.config.storage.credentials,
165
+ }));
166
+ }
167
+ }
168
+ const record = {
169
+ externalId,
170
+ sandboxRef: sandbox,
171
+ state: 'running',
172
+ createdAt,
173
+ };
174
+ this.sandboxes.set(id, record);
175
+ return wrapCloudflareSandbox(id, sandbox, 'running', createdAt, this.config.hostname, this.getSnapshotsFor(id));
176
+ }
177
+ catch (err) {
178
+ throw new ProviderError('cloudflare', err);
179
+ }
180
+ }
181
+ async getSandbox(id) {
182
+ const record = this.sandboxes.get(id);
183
+ if (!record || record.state === 'terminated') {
184
+ throw new SandboxNotFoundError('cloudflare', id);
185
+ }
186
+ // Reconnect lazily via getSandbox
187
+ const reconnectOpts = {};
188
+ if (this.config.keepAlive) {
189
+ reconnectOpts['keepAlive'] = true;
190
+ }
191
+ else if (this.config.sleepAfter) {
192
+ reconnectOpts['sleepAfter'] = this.config.sleepAfter;
193
+ }
194
+ const sandbox = getSandbox(this.config.namespace, record.externalId, reconnectOpts);
195
+ record.sandboxRef = sandbox;
196
+ return wrapCloudflareSandbox(id, sandbox, record.state, record.createdAt, this.config.hostname, this.getSnapshotsFor(id));
197
+ }
198
+ async listSandboxes(filter) {
199
+ let infos = [];
200
+ for (const [id, record] of this.sandboxes) {
201
+ infos.push({
202
+ id,
203
+ state: record.state,
204
+ createdAt: record.createdAt,
205
+ image: '', // CF container image is defined in wrangler.toml, not at runtime
206
+ });
207
+ }
208
+ // Apply state filter
209
+ if (filter?.state) {
210
+ const states = Array.isArray(filter.state) ? filter.state : [filter.state];
211
+ infos = infos.filter(s => states.includes(s.state));
212
+ }
213
+ // Apply limit
214
+ if (filter?.limit && filter.limit > 0) {
215
+ infos = infos.slice(0, filter.limit);
216
+ }
217
+ return infos;
218
+ }
219
+ async destroySandbox(id) {
220
+ const record = this.sandboxes.get(id);
221
+ if (!record)
222
+ return; // idempotent
223
+ if (record.state === 'terminated')
224
+ return; // already destroyed
225
+ try {
226
+ const sandbox = getSandbox(this.config.namespace, record.externalId);
227
+ await sandbox.destroy();
228
+ }
229
+ catch {
230
+ // Idempotent: ignore errors during destroy
231
+ }
232
+ record.state = 'terminated';
233
+ }
234
+ // --- Volume operations ---
235
+ async createVolume(config) {
236
+ // Lightweight registration: trust that the R2 bucket already exists
237
+ const id = config.name;
238
+ this.volumes.set(id, { id, name: config.name });
239
+ return {
240
+ id,
241
+ name: config.name,
242
+ sizeGB: config.sizeGB ?? 0,
243
+ attachedTo: null,
244
+ };
245
+ }
246
+ async deleteVolume(id) {
247
+ // Just remove from internal tracking; R2 bucket lifecycle is managed by the user
248
+ this.volumes.delete(id);
249
+ }
250
+ async listVolumes() {
251
+ return Array.from(this.volumes.values()).map(v => ({
252
+ id: v.id,
253
+ name: v.name,
254
+ sizeGB: 0,
255
+ attachedTo: null,
256
+ }));
257
+ }
258
+ }
@@ -0,0 +1,2 @@
1
+ export { CloudflareAdapter, type CloudflareAdapterConfig } from './adapter.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,cAAc,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { CloudflareAdapter } from './adapter.js';
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@douglas-agent/sandbank-cloudflare",
3
+ "version": "0.2.0",
4
+ "description": "Cloudflare Workers sandbox adapter for Sandbank",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "homepage": "https://sandbank.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/Xeonice/sandbank-douglas-agent.git",
11
+ "directory": "packages/cloudflare"
12
+ },
13
+ "keywords": [
14
+ "sandbox",
15
+ "ai-agent",
16
+ "cloudflare",
17
+ "workers",
18
+ "cloud"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "typecheck": "tsc --noEmit",
32
+ "clean": "rm -rf dist",
33
+ "test:e2e": "vitest run --config vitest.e2e.config.ts"
34
+ },
35
+ "dependencies": {
36
+ "@cloudflare/sandbox": "^0.7.0",
37
+ "@douglas-agent/sandbank-core": "^0.3.6"
38
+ },
39
+ "devDependencies": {
40
+ "@cloudflare/workers-types": "^4.20250214.0",
41
+ "typescript": "^5.7.3",
42
+ "wrangler": "^4.0.0"
43
+ }
44
+ }