@cloudflare/sandbox 0.5.6 → 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/Dockerfile +54 -56
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +11 -6
- package/.turbo/turbo-build.log +0 -23
- package/CHANGELOG.md +0 -463
- package/src/clients/base-client.ts +0 -356
- package/src/clients/command-client.ts +0 -133
- package/src/clients/file-client.ts +0 -300
- package/src/clients/git-client.ts +0 -98
- package/src/clients/index.ts +0 -64
- package/src/clients/interpreter-client.ts +0 -339
- package/src/clients/port-client.ts +0 -105
- package/src/clients/process-client.ts +0 -198
- package/src/clients/sandbox-client.ts +0 -39
- package/src/clients/types.ts +0 -88
- package/src/clients/utility-client.ts +0 -156
- package/src/errors/adapter.ts +0 -238
- package/src/errors/classes.ts +0 -594
- package/src/errors/index.ts +0 -109
- package/src/file-stream.ts +0 -175
- package/src/index.ts +0 -121
- package/src/interpreter.ts +0 -168
- package/src/openai/index.ts +0 -465
- package/src/request-handler.ts +0 -184
- package/src/sandbox.ts +0 -1937
- package/src/security.ts +0 -119
- package/src/sse-parser.ts +0 -147
- package/src/storage-mount/credential-detection.ts +0 -41
- package/src/storage-mount/errors.ts +0 -51
- package/src/storage-mount/index.ts +0 -17
- package/src/storage-mount/provider-detection.ts +0 -93
- package/src/storage-mount/types.ts +0 -17
- package/src/version.ts +0 -6
- package/tests/base-client.test.ts +0 -582
- package/tests/command-client.test.ts +0 -444
- package/tests/file-client.test.ts +0 -831
- package/tests/file-stream.test.ts +0 -310
- package/tests/get-sandbox.test.ts +0 -172
- package/tests/git-client.test.ts +0 -455
- package/tests/openai-shell-editor.test.ts +0 -434
- package/tests/port-client.test.ts +0 -283
- package/tests/process-client.test.ts +0 -649
- package/tests/request-handler.test.ts +0 -292
- package/tests/sandbox.test.ts +0 -890
- package/tests/sse-parser.test.ts +0 -291
- package/tests/storage-mount/credential-detection.test.ts +0 -119
- package/tests/storage-mount/provider-detection.test.ts +0 -77
- package/tests/utility-client.test.ts +0 -339
- package/tests/version.test.ts +0 -16
- package/tests/wrangler.jsonc +0 -35
- package/tsconfig.json +0 -11
- package/tsdown.config.ts +0 -13
- package/vitest.config.ts +0 -31
package/src/openai/index.ts
DELETED
|
@@ -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
|
-
}
|
package/src/request-handler.ts
DELETED
|
@@ -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
|
-
}
|