@cloudflare/sandbox 0.5.1 → 0.5.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/.turbo/turbo-build.log +17 -9
- package/CHANGELOG.md +18 -0
- package/dist/dist-gVyG2H2h.js +612 -0
- package/dist/dist-gVyG2H2h.js.map +1 -0
- package/dist/index.d.ts +14 -1720
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +82 -698
- package/dist/index.js.map +1 -1
- package/dist/openai/index.d.ts +67 -0
- package/dist/openai/index.d.ts.map +1 -0
- package/dist/openai/index.js +362 -0
- package/dist/openai/index.js.map +1 -0
- package/dist/sandbox-HQazw9bn.d.ts +1741 -0
- package/dist/sandbox-HQazw9bn.d.ts.map +1 -0
- package/package.json +15 -1
- package/src/clients/command-client.ts +31 -13
- package/src/clients/process-client.ts +20 -2
- package/src/openai/index.ts +465 -0
- package/src/sandbox.ts +103 -47
- package/src/version.ts +1 -1
- package/tests/git-client.test.ts +7 -39
- package/tests/openai-shell-editor.test.ts +434 -0
- package/tests/port-client.test.ts +25 -35
- package/tests/process-client.test.ts +73 -107
- package/tests/sandbox.test.ts +65 -35
- package/tsconfig.json +2 -2
- package/tsdown.config.ts +1 -1
|
@@ -0,0 +1,465 @@
|
|
|
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
|
+
}
|
package/src/sandbox.ts
CHANGED
|
@@ -22,7 +22,6 @@ import type {
|
|
|
22
22
|
import {
|
|
23
23
|
createLogger,
|
|
24
24
|
getEnvString,
|
|
25
|
-
runWithLogger,
|
|
26
25
|
type SessionDeleteResult,
|
|
27
26
|
shellEscape,
|
|
28
27
|
TraceContext
|
|
@@ -757,8 +756,20 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
757
756
|
}
|
|
758
757
|
}
|
|
759
758
|
|
|
760
|
-
override onStop() {
|
|
759
|
+
override async onStop() {
|
|
761
760
|
this.logger.debug('Sandbox stopped');
|
|
761
|
+
|
|
762
|
+
// Clear in-memory state that references the old container
|
|
763
|
+
// This prevents stale references after container restarts
|
|
764
|
+
this.portTokens.clear();
|
|
765
|
+
this.defaultSession = null;
|
|
766
|
+
this.activeMounts.clear();
|
|
767
|
+
|
|
768
|
+
// Persist cleanup to storage so state is clean on next container start
|
|
769
|
+
await Promise.all([
|
|
770
|
+
this.ctx.storage.delete('portTokens'),
|
|
771
|
+
this.ctx.storage.delete('defaultSession')
|
|
772
|
+
]);
|
|
762
773
|
}
|
|
763
774
|
|
|
764
775
|
override onError(error: unknown) {
|
|
@@ -905,48 +916,46 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
905
916
|
// Create request-specific logger with trace ID
|
|
906
917
|
const requestLogger = this.logger.child({ traceId, operation: 'fetch' });
|
|
907
918
|
|
|
908
|
-
|
|
909
|
-
const url = new URL(request.url);
|
|
919
|
+
const url = new URL(request.url);
|
|
910
920
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
921
|
+
// Capture and store the sandbox name from the header if present
|
|
922
|
+
if (!this.sandboxName && request.headers.has('X-Sandbox-Name')) {
|
|
923
|
+
const name = request.headers.get('X-Sandbox-Name')!;
|
|
924
|
+
this.sandboxName = name;
|
|
925
|
+
await this.ctx.storage.put('sandboxName', name);
|
|
926
|
+
}
|
|
917
927
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
928
|
+
// Detect WebSocket upgrade request (RFC 6455 compliant)
|
|
929
|
+
const upgradeHeader = request.headers.get('Upgrade');
|
|
930
|
+
const connectionHeader = request.headers.get('Connection');
|
|
931
|
+
const isWebSocket =
|
|
932
|
+
upgradeHeader?.toLowerCase() === 'websocket' &&
|
|
933
|
+
connectionHeader?.toLowerCase().includes('upgrade');
|
|
924
934
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
}
|
|
935
|
+
if (isWebSocket) {
|
|
936
|
+
// WebSocket path: Let parent Container class handle WebSocket proxying
|
|
937
|
+
// This bypasses containerFetch() which uses JSRPC and cannot handle WebSocket upgrades
|
|
938
|
+
try {
|
|
939
|
+
requestLogger.debug('WebSocket upgrade requested', {
|
|
940
|
+
path: url.pathname,
|
|
941
|
+
port: this.determinePort(url)
|
|
942
|
+
});
|
|
943
|
+
return await super.fetch(request);
|
|
944
|
+
} catch (error) {
|
|
945
|
+
requestLogger.error(
|
|
946
|
+
'WebSocket connection failed',
|
|
947
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
948
|
+
{ path: url.pathname }
|
|
949
|
+
);
|
|
950
|
+
throw error;
|
|
942
951
|
}
|
|
952
|
+
}
|
|
943
953
|
|
|
944
|
-
|
|
945
|
-
|
|
954
|
+
// Non-WebSocket: Use existing port determination and HTTP routing logic
|
|
955
|
+
const port = this.determinePort(url);
|
|
946
956
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
});
|
|
957
|
+
// Route to the appropriate port
|
|
958
|
+
return await this.containerFetch(request, port);
|
|
950
959
|
}
|
|
951
960
|
|
|
952
961
|
wsConnect(request: Request, port: number): Promise<Response> {
|
|
@@ -1051,7 +1060,23 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
1051
1060
|
);
|
|
1052
1061
|
} else {
|
|
1053
1062
|
// Regular execution with session
|
|
1054
|
-
const
|
|
1063
|
+
const commandOptions =
|
|
1064
|
+
options &&
|
|
1065
|
+
(options.timeout !== undefined ||
|
|
1066
|
+
options.env !== undefined ||
|
|
1067
|
+
options.cwd !== undefined)
|
|
1068
|
+
? {
|
|
1069
|
+
timeoutMs: options.timeout,
|
|
1070
|
+
env: options.env,
|
|
1071
|
+
cwd: options.cwd
|
|
1072
|
+
}
|
|
1073
|
+
: undefined;
|
|
1074
|
+
|
|
1075
|
+
const response = await this.client.commands.execute(
|
|
1076
|
+
command,
|
|
1077
|
+
sessionId,
|
|
1078
|
+
commandOptions
|
|
1079
|
+
);
|
|
1055
1080
|
|
|
1056
1081
|
const duration = Date.now() - startTime;
|
|
1057
1082
|
result = this.mapExecuteResponseToExecResult(
|
|
@@ -1092,7 +1117,12 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
1092
1117
|
try {
|
|
1093
1118
|
const stream = await this.client.commands.executeStream(
|
|
1094
1119
|
command,
|
|
1095
|
-
sessionId
|
|
1120
|
+
sessionId,
|
|
1121
|
+
{
|
|
1122
|
+
timeoutMs: options.timeout,
|
|
1123
|
+
env: options.env,
|
|
1124
|
+
cwd: options.cwd
|
|
1125
|
+
}
|
|
1096
1126
|
);
|
|
1097
1127
|
|
|
1098
1128
|
for await (const event of parseSSEStream<ExecEvent>(stream)) {
|
|
@@ -1222,12 +1252,23 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
1222
1252
|
// Use the new HttpClient method to start the process
|
|
1223
1253
|
try {
|
|
1224
1254
|
const session = sessionId ?? (await this.ensureDefaultSession());
|
|
1255
|
+
const requestOptions = {
|
|
1256
|
+
...(options?.processId !== undefined && {
|
|
1257
|
+
processId: options.processId
|
|
1258
|
+
}),
|
|
1259
|
+
...(options?.timeout !== undefined && { timeoutMs: options.timeout }),
|
|
1260
|
+
...(options?.env !== undefined && { env: options.env }),
|
|
1261
|
+
...(options?.cwd !== undefined && { cwd: options.cwd }),
|
|
1262
|
+
...(options?.encoding !== undefined && { encoding: options.encoding }),
|
|
1263
|
+
...(options?.autoCleanup !== undefined && {
|
|
1264
|
+
autoCleanup: options.autoCleanup
|
|
1265
|
+
})
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1225
1268
|
const response = await this.client.processes.startProcess(
|
|
1226
1269
|
command,
|
|
1227
1270
|
session,
|
|
1228
|
-
|
|
1229
|
-
processId: options?.processId
|
|
1230
|
-
}
|
|
1271
|
+
requestOptions
|
|
1231
1272
|
);
|
|
1232
1273
|
|
|
1233
1274
|
const processObj = this.createProcessFromDTO(
|
|
@@ -1347,7 +1388,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
1347
1388
|
|
|
1348
1389
|
const session = await this.ensureDefaultSession();
|
|
1349
1390
|
// Get the stream from CommandClient
|
|
1350
|
-
return this.client.commands.executeStream(command, session
|
|
1391
|
+
return this.client.commands.executeStream(command, session, {
|
|
1392
|
+
timeoutMs: options?.timeout,
|
|
1393
|
+
env: options?.env,
|
|
1394
|
+
cwd: options?.cwd
|
|
1395
|
+
});
|
|
1351
1396
|
}
|
|
1352
1397
|
|
|
1353
1398
|
/**
|
|
@@ -1363,7 +1408,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
1363
1408
|
throw new Error('Operation was aborted');
|
|
1364
1409
|
}
|
|
1365
1410
|
|
|
1366
|
-
return this.client.commands.executeStream(command, sessionId
|
|
1411
|
+
return this.client.commands.executeStream(command, sessionId, {
|
|
1412
|
+
timeoutMs: options?.timeout,
|
|
1413
|
+
env: options?.env,
|
|
1414
|
+
cwd: options?.cwd
|
|
1415
|
+
});
|
|
1367
1416
|
}
|
|
1368
1417
|
|
|
1369
1418
|
/**
|
|
@@ -1697,11 +1746,18 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
|
|
|
1697
1746
|
async createSession(options?: SessionOptions): Promise<ExecutionSession> {
|
|
1698
1747
|
const sessionId = options?.id || `session-${Date.now()}`;
|
|
1699
1748
|
|
|
1749
|
+
const mergedEnv = {
|
|
1750
|
+
...this.envVars,
|
|
1751
|
+
...(options?.env ?? {})
|
|
1752
|
+
};
|
|
1753
|
+
const envPayload =
|
|
1754
|
+
Object.keys(mergedEnv).length > 0 ? mergedEnv : undefined;
|
|
1755
|
+
|
|
1700
1756
|
// Create session in container
|
|
1701
1757
|
await this.client.utils.createSession({
|
|
1702
1758
|
id: sessionId,
|
|
1703
|
-
env:
|
|
1704
|
-
cwd: options
|
|
1759
|
+
...(envPayload && { env: envPayload }),
|
|
1760
|
+
...(options?.cwd && { cwd: options.cwd })
|
|
1705
1761
|
});
|
|
1706
1762
|
|
|
1707
1763
|
// Return wrapper that binds sessionId to all operations
|
package/src/version.ts
CHANGED