@cloudflare/sandbox 0.5.4 → 0.6.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.
Files changed (57) hide show
  1. package/Dockerfile +54 -59
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +12 -1
  6. package/dist/index.js.map +1 -1
  7. package/package.json +13 -8
  8. package/.turbo/turbo-build.log +0 -23
  9. package/CHANGELOG.md +0 -441
  10. package/src/clients/base-client.ts +0 -356
  11. package/src/clients/command-client.ts +0 -133
  12. package/src/clients/file-client.ts +0 -300
  13. package/src/clients/git-client.ts +0 -98
  14. package/src/clients/index.ts +0 -64
  15. package/src/clients/interpreter-client.ts +0 -333
  16. package/src/clients/port-client.ts +0 -105
  17. package/src/clients/process-client.ts +0 -198
  18. package/src/clients/sandbox-client.ts +0 -39
  19. package/src/clients/types.ts +0 -88
  20. package/src/clients/utility-client.ts +0 -156
  21. package/src/errors/adapter.ts +0 -238
  22. package/src/errors/classes.ts +0 -594
  23. package/src/errors/index.ts +0 -109
  24. package/src/file-stream.ts +0 -169
  25. package/src/index.ts +0 -121
  26. package/src/interpreter.ts +0 -168
  27. package/src/openai/index.ts +0 -465
  28. package/src/request-handler.ts +0 -184
  29. package/src/sandbox.ts +0 -1937
  30. package/src/security.ts +0 -119
  31. package/src/sse-parser.ts +0 -144
  32. package/src/storage-mount/credential-detection.ts +0 -41
  33. package/src/storage-mount/errors.ts +0 -51
  34. package/src/storage-mount/index.ts +0 -17
  35. package/src/storage-mount/provider-detection.ts +0 -93
  36. package/src/storage-mount/types.ts +0 -17
  37. package/src/version.ts +0 -6
  38. package/tests/base-client.test.ts +0 -582
  39. package/tests/command-client.test.ts +0 -444
  40. package/tests/file-client.test.ts +0 -831
  41. package/tests/file-stream.test.ts +0 -310
  42. package/tests/get-sandbox.test.ts +0 -172
  43. package/tests/git-client.test.ts +0 -455
  44. package/tests/openai-shell-editor.test.ts +0 -434
  45. package/tests/port-client.test.ts +0 -283
  46. package/tests/process-client.test.ts +0 -649
  47. package/tests/request-handler.test.ts +0 -292
  48. package/tests/sandbox.test.ts +0 -890
  49. package/tests/sse-parser.test.ts +0 -291
  50. package/tests/storage-mount/credential-detection.test.ts +0 -119
  51. package/tests/storage-mount/provider-detection.test.ts +0 -77
  52. package/tests/utility-client.test.ts +0 -339
  53. package/tests/version.test.ts +0 -16
  54. package/tests/wrangler.jsonc +0 -35
  55. package/tsconfig.json +0 -11
  56. package/tsdown.config.ts +0 -13
  57. package/vitest.config.ts +0 -31
@@ -1,465 +0,0 @@
1
- /**
2
- * OpenAI Agents adapters for executing shell commands and file operations
3
- * inside a Cloudflare Sandbox.
4
- */
5
- import {
6
- type ApplyPatchOperation,
7
- type ApplyPatchResult,
8
- applyDiff,
9
- type Editor as OpenAIEeditor,
10
- type Shell as OpenAIShell,
11
- type ShellAction,
12
- type ShellOutputResult,
13
- type ShellResult
14
- } from '@openai/agents';
15
-
16
- // Command result for API responses
17
- export interface CommandResult {
18
- command: string;
19
- stdout: string;
20
- stderr: string;
21
- exitCode: number | null;
22
- timestamp: number;
23
- }
24
-
25
- // File operation result for API responses
26
- export interface FileOperationResult {
27
- operation: 'create' | 'update' | 'delete';
28
- path: string;
29
- status: 'completed' | 'failed';
30
- output: string;
31
- error?: string;
32
- timestamp: number;
33
- }
34
-
35
- import { createLogger, type Logger } from '@repo/shared';
36
- import type { Sandbox } from '../sandbox';
37
-
38
- // Helper functions for error handling
39
- function isErrorWithProperties(error: unknown): error is {
40
- message?: string;
41
- exitCode?: number;
42
- stdout?: string;
43
- stderr?: string;
44
- status?: number;
45
- stack?: string;
46
- } {
47
- return typeof error === 'object' && error !== null;
48
- }
49
-
50
- function getErrorMessage(error: unknown): string {
51
- if (isErrorWithProperties(error) && typeof error.message === 'string') {
52
- return error.message;
53
- }
54
- return String(error);
55
- }
56
-
57
- /**
58
- * Convert unknown values to Error instances when possible so downstream
59
- * loggers can include stack traces without losing type safety.
60
- */
61
- function toError(error: unknown): Error | undefined {
62
- return error instanceof Error ? error : undefined;
63
- }
64
-
65
- /**
66
- * Shell implementation that adapts Cloudflare Sandbox exec calls to the
67
- * OpenAI Agents `Shell` contract, including structured result collection.
68
- */
69
- export class Shell implements OpenAIShell {
70
- private cwd: string = '/workspace';
71
- public results: CommandResult[] = [];
72
- private readonly logger: Logger;
73
-
74
- constructor(private readonly sandbox: Sandbox) {
75
- this.logger = createLogger({
76
- component: 'sandbox-do',
77
- operation: 'openai-shell'
78
- });
79
- }
80
-
81
- async run(action: ShellAction): Promise<ShellResult> {
82
- this.logger.debug('SandboxShell.run called', {
83
- commands: action.commands,
84
- timeout: action.timeoutMs
85
- });
86
- const output: ShellResult['output'] = [];
87
-
88
- for (const command of action.commands) {
89
- this.logger.debug('Executing command', { command, cwd: this.cwd });
90
- let stdout = '';
91
- let stderr = '';
92
- let exitCode: number | null = 0;
93
- let outcome: ShellOutputResult['outcome'] = {
94
- type: 'exit',
95
- exitCode: 0
96
- };
97
- try {
98
- const result = await this.sandbox.exec(command, {
99
- timeout: action.timeoutMs,
100
- cwd: this.cwd
101
- });
102
- stdout = result.stdout;
103
- stderr = result.stderr;
104
- exitCode = result.exitCode;
105
- // exec returns a result even for failed commands, so check success field
106
- // Timeout would be indicated by a specific error or exit code
107
- outcome = { type: 'exit', exitCode };
108
-
109
- this.logger.debug('Command executed successfully', {
110
- command,
111
- exitCode,
112
- stdoutLength: stdout.length,
113
- stderrLength: stderr.length
114
- });
115
-
116
- // Log warnings for non-zero exit codes or stderr output
117
- if (exitCode !== 0) {
118
- this.logger.warn(`Command failed with exit code ${exitCode}`, {
119
- command,
120
- stderr
121
- });
122
- } else if (stderr) {
123
- this.logger.warn(`Command produced stderr output`, {
124
- command,
125
- stderr
126
- });
127
- } else {
128
- this.logger.info(`Command completed successfully`, { command });
129
- }
130
- } catch (error: unknown) {
131
- // Handle network/HTTP errors or timeout errors
132
- const errorObj = isErrorWithProperties(error) ? error : {};
133
- exitCode =
134
- typeof errorObj.exitCode === 'number' ? errorObj.exitCode : null;
135
- stdout = typeof errorObj.stdout === 'string' ? errorObj.stdout : '';
136
- stderr = typeof errorObj.stderr === 'string' ? errorObj.stderr : '';
137
-
138
- // Check if it's a timeout error
139
- const errorMessage = getErrorMessage(error);
140
- if (
141
- errorMessage.includes('timeout') ||
142
- errorMessage.includes('Timeout') ||
143
- errorMessage.includes('timed out')
144
- ) {
145
- this.logger.error(`Command timed out`, undefined, {
146
- command,
147
- timeout: action.timeoutMs
148
- });
149
- outcome = { type: 'timeout' };
150
- } else {
151
- this.logger.error(`Error executing command`, toError(error), {
152
- command,
153
- error: errorMessage || error,
154
- exitCode
155
- });
156
- outcome = { type: 'exit', exitCode: exitCode ?? 1 };
157
- }
158
- }
159
- output.push({
160
- command,
161
- stdout,
162
- stderr,
163
- outcome
164
- });
165
-
166
- // Collect results for API responses
167
- const collectedExitCode =
168
- outcome.type === 'exit' ? outcome.exitCode : null;
169
- const timestamp = Date.now();
170
- this.results.push({
171
- command: String(command),
172
- stdout: String(stdout),
173
- stderr: String(stderr),
174
- exitCode: collectedExitCode,
175
- timestamp
176
- });
177
- this.logger.debug('Result collected', {
178
- command,
179
- exitCode: collectedExitCode,
180
- timestamp
181
- });
182
-
183
- if (outcome.type === 'timeout') {
184
- this.logger.warn('Breaking command loop due to timeout');
185
- break;
186
- }
187
- }
188
-
189
- this.logger.debug('SandboxShell.run completed', {
190
- totalCommands: action.commands.length,
191
- resultsCount: this.results.length
192
- });
193
- return {
194
- output,
195
- providerData: {
196
- working_directory: this.cwd
197
- }
198
- };
199
- }
200
- }
201
-
202
- /**
203
- * Editor implementation that projects applyPatch operations from Agents
204
- * into calls against the sandbox filesystem APIs.
205
- */
206
- export class Editor implements OpenAIEeditor {
207
- public results: FileOperationResult[] = [];
208
- private readonly logger: Logger;
209
-
210
- constructor(
211
- private readonly sandbox: Sandbox,
212
- private readonly root: string = '/workspace'
213
- ) {
214
- this.logger = createLogger({
215
- component: 'sandbox-do',
216
- operation: 'openai-editor'
217
- });
218
- }
219
-
220
- /**
221
- * Create a new file inside the sandbox by applying the provided diff.
222
- */
223
- async createFile(
224
- operation: Extract<ApplyPatchOperation, { type: 'create_file' }>
225
- ): Promise<ApplyPatchResult | undefined> {
226
- const targetPath = this.resolve(operation.path);
227
- this.logger.debug('WorkspaceEditor.createFile called', {
228
- path: operation.path,
229
- targetPath
230
- });
231
-
232
- try {
233
- // Create parent directory if needed
234
- const dirPath = this.getDirname(targetPath);
235
- if (dirPath !== this.root && dirPath !== '/') {
236
- this.logger.debug('Creating parent directory', { dirPath });
237
- await this.sandbox.mkdir(dirPath, { recursive: true });
238
- }
239
-
240
- const content = applyDiff('', operation.diff, 'create');
241
- this.logger.debug('Writing file content', {
242
- path: targetPath,
243
- contentLength: content.length
244
- });
245
- await this.sandbox.writeFile(targetPath, content, { encoding: 'utf-8' });
246
- const timestamp = Date.now();
247
- const result: FileOperationResult = {
248
- operation: 'create',
249
- path: operation.path,
250
- status: 'completed',
251
- output: `Created ${operation.path}`,
252
- timestamp
253
- };
254
- this.results.push(result);
255
- this.logger.info('File created successfully', {
256
- path: operation.path,
257
- timestamp
258
- });
259
- return { status: 'completed', output: `Created ${operation.path}` };
260
- } catch (error: unknown) {
261
- const timestamp = Date.now();
262
- const errorMessage = getErrorMessage(error);
263
- const result: FileOperationResult = {
264
- operation: 'create',
265
- path: operation.path,
266
- status: 'failed',
267
- output: `Failed to create ${operation.path}`,
268
- error: errorMessage,
269
- timestamp
270
- };
271
- this.results.push(result);
272
- this.logger.error('Failed to create file', toError(error), {
273
- path: operation.path,
274
- error: errorMessage
275
- });
276
- throw error;
277
- }
278
- }
279
-
280
- /**
281
- * Update an existing file by reading its content, applying a diff, and
282
- * writing the patched output back to the sandbox.
283
- */
284
- async updateFile(
285
- operation: Extract<ApplyPatchOperation, { type: 'update_file' }>
286
- ): Promise<ApplyPatchResult | undefined> {
287
- const targetPath = this.resolve(operation.path);
288
- this.logger.debug('WorkspaceEditor.updateFile called', {
289
- path: operation.path,
290
- targetPath
291
- });
292
-
293
- try {
294
- let original: string;
295
- try {
296
- this.logger.debug('Reading original file', { path: targetPath });
297
- const fileInfo = await this.sandbox.readFile(targetPath, {
298
- encoding: 'utf-8'
299
- });
300
- original = fileInfo.content;
301
- this.logger.debug('Original file read', {
302
- path: targetPath,
303
- originalLength: original.length
304
- });
305
- } catch (error: unknown) {
306
- // Sandbox API may throw errors for missing files
307
- const errorObj = isErrorWithProperties(error) ? error : {};
308
- const errorMessage = getErrorMessage(error);
309
- if (
310
- errorMessage.includes('not found') ||
311
- errorMessage.includes('ENOENT') ||
312
- errorObj.status === 404
313
- ) {
314
- this.logger.error('Cannot update missing file', undefined, {
315
- path: operation.path
316
- });
317
- throw new Error(`Cannot update missing file: ${operation.path}`);
318
- }
319
- this.logger.error('Error reading file', toError(error), {
320
- path: operation.path,
321
- error: errorMessage
322
- });
323
- throw error;
324
- }
325
-
326
- const patched = applyDiff(original, operation.diff);
327
- this.logger.debug('Applied diff', {
328
- path: targetPath,
329
- originalLength: original.length,
330
- patchedLength: patched.length
331
- });
332
- await this.sandbox.writeFile(targetPath, patched, { encoding: 'utf-8' });
333
- const timestamp = Date.now();
334
- const result: FileOperationResult = {
335
- operation: 'update',
336
- path: operation.path,
337
- status: 'completed',
338
- output: `Updated ${operation.path}`,
339
- timestamp
340
- };
341
- this.results.push(result);
342
- this.logger.info('File updated successfully', {
343
- path: operation.path,
344
- timestamp
345
- });
346
- return { status: 'completed', output: `Updated ${operation.path}` };
347
- } catch (error: unknown) {
348
- const timestamp = Date.now();
349
- const errorMessage = getErrorMessage(error);
350
- const result: FileOperationResult = {
351
- operation: 'update',
352
- path: operation.path,
353
- status: 'failed',
354
- output: `Failed to update ${operation.path}`,
355
- error: errorMessage,
356
- timestamp
357
- };
358
- this.results.push(result);
359
- this.logger.error('Failed to update file', toError(error), {
360
- path: operation.path,
361
- error: errorMessage
362
- });
363
- throw error;
364
- }
365
- }
366
-
367
- /**
368
- * Delete a file that was previously created through applyPatch calls.
369
- */
370
- async deleteFile(
371
- operation: Extract<ApplyPatchOperation, { type: 'delete_file' }>
372
- ): Promise<ApplyPatchResult | undefined> {
373
- const targetPath = this.resolve(operation.path);
374
- this.logger.debug('WorkspaceEditor.deleteFile called', {
375
- path: operation.path,
376
- targetPath
377
- });
378
-
379
- try {
380
- await this.sandbox.deleteFile(targetPath);
381
- const timestamp = Date.now();
382
- const result: FileOperationResult = {
383
- operation: 'delete',
384
- path: operation.path,
385
- status: 'completed',
386
- output: `Deleted ${operation.path}`,
387
- timestamp
388
- };
389
- this.results.push(result);
390
- this.logger.info('File deleted successfully', {
391
- path: operation.path,
392
- timestamp
393
- });
394
- return { status: 'completed', output: `Deleted ${operation.path}` };
395
- } catch (error: unknown) {
396
- const timestamp = Date.now();
397
- const errorMessage = getErrorMessage(error);
398
- const result: FileOperationResult = {
399
- operation: 'delete',
400
- path: operation.path,
401
- status: 'failed',
402
- output: `Failed to delete ${operation.path}`,
403
- error: errorMessage,
404
- timestamp
405
- };
406
- this.results.push(result);
407
- this.logger.error('Failed to delete file', toError(error), {
408
- path: operation.path,
409
- error: errorMessage
410
- });
411
- throw error;
412
- }
413
- }
414
-
415
- private resolve(relativePath: string): string {
416
- // If the path already starts with the root, strip it to get the relative part
417
- let pathToProcess = relativePath;
418
- if (relativePath.startsWith(this.root)) {
419
- pathToProcess = relativePath.slice(this.root.length);
420
- // Remove leading slash if present after stripping root
421
- pathToProcess = pathToProcess.replace(/^\//, '');
422
- }
423
-
424
- // Remove leading ./ or / if present, then join with root
425
- const normalized = pathToProcess.replace(/^\.\//, '').replace(/^\//, '');
426
- const resolved = normalized ? `${this.root}/${normalized}` : this.root;
427
-
428
- // Normalize path separators first
429
- const pathWithNormalizedSeparators = resolved.replace(/\/+/g, '/');
430
-
431
- // Normalize .. segments by processing path segments
432
- const segments = pathWithNormalizedSeparators
433
- .split('/')
434
- .filter((s) => s && s !== '.');
435
- const stack: string[] = [];
436
-
437
- for (const segment of segments) {
438
- if (segment === '..') {
439
- if (stack.length === 0) {
440
- throw new Error(`Operation outside workspace: ${relativePath}`);
441
- }
442
- stack.pop();
443
- } else {
444
- stack.push(segment);
445
- }
446
- }
447
-
448
- const normalizedPath = `/${stack.join('/')}`;
449
-
450
- // Ensure the resolved path is within the workspace
451
- if (!normalizedPath.startsWith(this.root)) {
452
- throw new Error(`Operation outside workspace: ${relativePath}`);
453
- }
454
-
455
- return normalizedPath;
456
- }
457
-
458
- private getDirname(filePath: string): string {
459
- const lastSlash = filePath.lastIndexOf('/');
460
- if (lastSlash === -1) {
461
- return '/';
462
- }
463
- return filePath.substring(0, lastSlash) || '/';
464
- }
465
- }
@@ -1,184 +0,0 @@
1
- import { switchPort } from '@cloudflare/containers';
2
- import { createLogger, type LogContext, TraceContext } from '@repo/shared';
3
- import { getSandbox, type Sandbox } from './sandbox';
4
- import { sanitizeSandboxId, validatePort } from './security';
5
-
6
- export interface SandboxEnv {
7
- Sandbox: DurableObjectNamespace<Sandbox>;
8
- }
9
-
10
- export interface RouteInfo {
11
- port: number;
12
- sandboxId: string;
13
- path: string;
14
- token: string;
15
- }
16
-
17
- export async function proxyToSandbox<E extends SandboxEnv>(
18
- request: Request,
19
- env: E
20
- ): Promise<Response | null> {
21
- // Create logger context for this request
22
- const traceId =
23
- TraceContext.fromHeaders(request.headers) || TraceContext.generate();
24
- const logger = createLogger({
25
- component: 'sandbox-do',
26
- traceId,
27
- operation: 'proxy'
28
- });
29
-
30
- try {
31
- const url = new URL(request.url);
32
- const routeInfo = extractSandboxRoute(url);
33
-
34
- if (!routeInfo) {
35
- return null; // Not a request to an exposed container port
36
- }
37
-
38
- const { sandboxId, port, path, token } = routeInfo;
39
- // Preview URLs always use normalized (lowercase) IDs
40
- const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
41
-
42
- // Critical security check: Validate token (mandatory for all user ports)
43
- // Skip check for control plane port 3000
44
- if (port !== 3000) {
45
- // Validate the token matches the port
46
- const isValidToken = await sandbox.validatePortToken(port, token);
47
- if (!isValidToken) {
48
- logger.warn('Invalid token access blocked', {
49
- port,
50
- sandboxId,
51
- path,
52
- hostname: url.hostname,
53
- url: request.url,
54
- method: request.method,
55
- userAgent: request.headers.get('User-Agent') || 'unknown'
56
- });
57
-
58
- return new Response(
59
- JSON.stringify({
60
- error: `Access denied: Invalid token or port not exposed`,
61
- code: 'INVALID_TOKEN'
62
- }),
63
- {
64
- status: 404,
65
- headers: {
66
- 'Content-Type': 'application/json'
67
- }
68
- }
69
- );
70
- }
71
- }
72
-
73
- // Detect WebSocket upgrade request
74
- const upgradeHeader = request.headers.get('Upgrade');
75
- if (upgradeHeader?.toLowerCase() === 'websocket') {
76
- // WebSocket path: Must use fetch() not containerFetch()
77
- // This bypasses JSRPC serialization boundary which cannot handle WebSocket upgrades
78
- return await sandbox.fetch(switchPort(request, port));
79
- }
80
-
81
- // Build proxy request with proper headers
82
- let proxyUrl: string;
83
-
84
- // Route based on the target port
85
- if (port !== 3000) {
86
- // Route directly to user's service on the specified port
87
- proxyUrl = `http://localhost:${port}${path}${url.search}`;
88
- } else {
89
- // Port 3000 is our control plane - route normally
90
- proxyUrl = `http://localhost:3000${path}${url.search}`;
91
- }
92
-
93
- const proxyRequest = new Request(proxyUrl, {
94
- method: request.method,
95
- headers: {
96
- ...Object.fromEntries(request.headers),
97
- 'X-Original-URL': request.url,
98
- 'X-Forwarded-Host': url.hostname,
99
- 'X-Forwarded-Proto': url.protocol.replace(':', ''),
100
- 'X-Sandbox-Name': sandboxId // Pass the friendly name
101
- },
102
- body: request.body,
103
- // @ts-expect-error - duplex required for body streaming in modern runtimes
104
- duplex: 'half'
105
- });
106
-
107
- return await sandbox.containerFetch(proxyRequest, port);
108
- } catch (error) {
109
- logger.error(
110
- 'Proxy routing error',
111
- error instanceof Error ? error : new Error(String(error))
112
- );
113
- return new Response('Proxy routing error', { status: 500 });
114
- }
115
- }
116
-
117
- function extractSandboxRoute(url: URL): RouteInfo | null {
118
- // Parse subdomain pattern: port-sandboxId-token.domain (tokens mandatory)
119
- // Token is always exactly 16 chars (generated by generatePortToken)
120
- const subdomainMatch = url.hostname.match(
121
- /^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9_-]{16})\.(.+)$/
122
- );
123
-
124
- if (!subdomainMatch) {
125
- return null;
126
- }
127
-
128
- const portStr = subdomainMatch[1];
129
- const sandboxId = subdomainMatch[2];
130
- const token = subdomainMatch[3]; // Mandatory token
131
- const domain = subdomainMatch[4];
132
-
133
- const port = parseInt(portStr, 10);
134
- if (!validatePort(port)) {
135
- return null;
136
- }
137
-
138
- let sanitizedSandboxId: string;
139
- try {
140
- sanitizedSandboxId = sanitizeSandboxId(sandboxId);
141
- } catch (error) {
142
- return null;
143
- }
144
-
145
- // DNS subdomain length limit is 63 characters
146
- if (sandboxId.length > 63) {
147
- return null;
148
- }
149
-
150
- return {
151
- port,
152
- sandboxId: sanitizedSandboxId,
153
- path: url.pathname || '/',
154
- token
155
- };
156
- }
157
-
158
- export function isLocalhostPattern(hostname: string): boolean {
159
- // Handle IPv6 addresses in brackets (with or without port)
160
- if (hostname.startsWith('[')) {
161
- if (hostname.includes(']:')) {
162
- // [::1]:port format
163
- const ipv6Part = hostname.substring(0, hostname.indexOf(']:') + 1);
164
- return ipv6Part === '[::1]';
165
- } else {
166
- // [::1] format without port
167
- return hostname === '[::1]';
168
- }
169
- }
170
-
171
- // Handle bare IPv6 without brackets
172
- if (hostname === '::1') {
173
- return true;
174
- }
175
-
176
- // For IPv4 and regular hostnames, split on colon to remove port
177
- const hostPart = hostname.split(':')[0];
178
-
179
- return (
180
- hostPart === 'localhost' ||
181
- hostPart === '127.0.0.1' ||
182
- hostPart === '0.0.0.0'
183
- );
184
- }