@cloudflare/sandbox 0.5.1 → 0.5.2

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/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
- return await runWithLogger(requestLogger, async () => {
909
- const url = new URL(request.url);
919
+ const url = new URL(request.url);
910
920
 
911
- // Capture and store the sandbox name from the header if present
912
- if (!this.sandboxName && request.headers.has('X-Sandbox-Name')) {
913
- const name = request.headers.get('X-Sandbox-Name')!;
914
- this.sandboxName = name;
915
- await this.ctx.storage.put('sandboxName', name);
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
- // Detect WebSocket upgrade request (RFC 6455 compliant)
919
- const upgradeHeader = request.headers.get('Upgrade');
920
- const connectionHeader = request.headers.get('Connection');
921
- const isWebSocket =
922
- upgradeHeader?.toLowerCase() === 'websocket' &&
923
- connectionHeader?.toLowerCase().includes('upgrade');
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
- if (isWebSocket) {
926
- // WebSocket path: Let parent Container class handle WebSocket proxying
927
- // This bypasses containerFetch() which uses JSRPC and cannot handle WebSocket upgrades
928
- try {
929
- requestLogger.debug('WebSocket upgrade requested', {
930
- path: url.pathname,
931
- port: this.determinePort(url)
932
- });
933
- return await super.fetch(request);
934
- } catch (error) {
935
- requestLogger.error(
936
- 'WebSocket connection failed',
937
- error instanceof Error ? error : new Error(String(error)),
938
- { path: url.pathname }
939
- );
940
- throw error;
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
- // Non-WebSocket: Use existing port determination and HTTP routing logic
945
- const port = this.determinePort(url);
954
+ // Non-WebSocket: Use existing port determination and HTTP routing logic
955
+ const port = this.determinePort(url);
946
956
 
947
- // Route to the appropriate port
948
- return await this.containerFetch(request, port);
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> {
package/src/version.ts CHANGED
@@ -3,4 +3,4 @@
3
3
  * This file is auto-updated by .github/changeset-version.ts during releases
4
4
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
5
5
  */
6
- export const SDK_VERSION = '0.5.1';
6
+ export const SDK_VERSION = '0.5.2';
@@ -1,5 +1,5 @@
1
+ import type { GitCheckoutResult } from '@repo/shared';
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import type { GitCheckoutResponse } from '../src/clients';
3
3
  import { GitClient } from '../src/clients/git-client';
4
4
  import {
5
5
  GitAuthenticationError,
@@ -35,12 +35,8 @@ describe('GitClient', () => {
35
35
 
36
36
  describe('repository cloning', () => {
37
37
  it('should clone public repositories successfully', async () => {
38
- const mockResponse: GitCheckoutResponse = {
38
+ const mockResponse: GitCheckoutResult = {
39
39
  success: true,
40
- stdout:
41
- "Cloning into 'react'...\nReceiving objects: 100% (1284/1284), done.",
42
- stderr: '',
43
- exitCode: 0,
44
40
  repoUrl: 'https://github.com/facebook/react.git',
45
41
  branch: 'main',
46
42
  targetDir: 'react',
@@ -59,15 +55,11 @@ describe('GitClient', () => {
59
55
  expect(result.success).toBe(true);
60
56
  expect(result.repoUrl).toBe('https://github.com/facebook/react.git');
61
57
  expect(result.branch).toBe('main');
62
- expect(result.exitCode).toBe(0);
63
58
  });
64
59
 
65
60
  it('should clone repositories to specific branches', async () => {
66
- const mockResponse: GitCheckoutResponse = {
61
+ const mockResponse: GitCheckoutResult = {
67
62
  success: true,
68
- stdout: "Cloning into 'project'...\nSwitching to branch 'development'",
69
- stderr: '',
70
- exitCode: 0,
71
63
  repoUrl: 'https://github.com/company/project.git',
72
64
  branch: 'development',
73
65
  targetDir: 'project',
@@ -89,11 +81,8 @@ describe('GitClient', () => {
89
81
  });
90
82
 
91
83
  it('should clone repositories to custom directories', async () => {
92
- const mockResponse: GitCheckoutResponse = {
84
+ const mockResponse: GitCheckoutResult = {
93
85
  success: true,
94
- stdout: "Cloning into 'workspace/my-app'...\nDone.",
95
- stderr: '',
96
- exitCode: 0,
97
86
  repoUrl: 'https://github.com/user/my-app.git',
98
87
  branch: 'main',
99
88
  targetDir: 'workspace/my-app',
@@ -115,12 +104,8 @@ describe('GitClient', () => {
115
104
  });
116
105
 
117
106
  it('should handle large repository clones with warnings', async () => {
118
- const mockResponse: GitCheckoutResponse = {
107
+ const mockResponse: GitCheckoutResult = {
119
108
  success: true,
120
- stdout:
121
- "Cloning into 'linux'...\nReceiving objects: 100% (8125432/8125432), 2.34 GiB, done.",
122
- stderr: 'warning: filtering not recognized by server',
123
- exitCode: 0,
124
109
  repoUrl: 'https://github.com/torvalds/linux.git',
125
110
  branch: 'master',
126
111
  targetDir: 'linux',
@@ -137,15 +122,11 @@ describe('GitClient', () => {
137
122
  );
138
123
 
139
124
  expect(result.success).toBe(true);
140
- expect(result.stderr).toContain('warning:');
141
125
  });
142
126
 
143
127
  it('should handle SSH repository URLs', async () => {
144
- const mockResponse: GitCheckoutResponse = {
128
+ const mockResponse: GitCheckoutResult = {
145
129
  success: true,
146
- stdout: "Cloning into 'private-project'...\nDone.",
147
- stderr: '',
148
- exitCode: 0,
149
130
  repoUrl: 'git@github.com:company/private-project.git',
150
131
  branch: 'main',
151
132
  targetDir: 'private-project',
@@ -175,8 +156,6 @@ describe('GitClient', () => {
175
156
  JSON.stringify({
176
157
  success: true,
177
158
  stdout: `Cloning into '${repoName}'...\nDone.`,
178
- stderr: '',
179
- exitCode: 0,
180
159
  repoUrl: body.repoUrl,
181
160
  branch: body.branch || 'main',
182
161
  targetDir: body.targetDir || repoName,
@@ -320,11 +299,8 @@ describe('GitClient', () => {
320
299
  });
321
300
 
322
301
  it('should handle partial clone failures', async () => {
323
- const mockResponse: GitCheckoutResponse = {
302
+ const mockResponse: GitCheckoutResult = {
324
303
  success: false,
325
- stdout: "Cloning into 'repo'...\nReceiving objects: 45% (450/1000)",
326
- stderr: 'error: RPC failed\nfatal: early EOF',
327
- exitCode: 128,
328
304
  repoUrl: 'https://github.com/problematic/repo.git',
329
305
  branch: 'main',
330
306
  targetDir: 'repo',
@@ -341,8 +317,6 @@ describe('GitClient', () => {
341
317
  );
342
318
 
343
319
  expect(result.success).toBe(false);
344
- expect(result.exitCode).toBe(128);
345
- expect(result.stderr).toContain('RPC failed');
346
320
  });
347
321
  });
348
322
 
@@ -434,9 +408,6 @@ describe('GitClient', () => {
434
408
  new Response(
435
409
  JSON.stringify({
436
410
  success: true,
437
- stdout: "Cloning into 'private-repo'...\nDone.",
438
- stderr: '',
439
- exitCode: 0,
440
411
  repoUrl:
441
412
  'https://oauth2:ghp_token123@github.com/user/private-repo.git',
442
413
  branch: 'main',
@@ -463,9 +434,6 @@ describe('GitClient', () => {
463
434
  new Response(
464
435
  JSON.stringify({
465
436
  success: true,
466
- stdout: "Cloning into 'react'...\nDone.",
467
- stderr: '',
468
- exitCode: 0,
469
437
  repoUrl: 'https://github.com/facebook/react.git',
470
438
  branch: 'main',
471
439
  targetDir: '/workspace/react',
@@ -0,0 +1,434 @@
1
+ import type { ApplyPatchOperation } from '@openai/agents';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { Editor, Shell } from '../src/openai/index';
4
+ import type { Sandbox } from '../src/sandbox';
5
+
6
+ interface MockSandbox {
7
+ exec?: ReturnType<typeof vi.fn>;
8
+ mkdir?: ReturnType<typeof vi.fn>;
9
+ writeFile?: ReturnType<typeof vi.fn>;
10
+ readFile?: ReturnType<typeof vi.fn>;
11
+ deleteFile?: ReturnType<typeof vi.fn>;
12
+ }
13
+
14
+ const { loggerSpies, createLoggerMock, applyDiffMock } = vi.hoisted(() => {
15
+ const logger = {
16
+ debug: vi.fn(),
17
+ info: vi.fn(),
18
+ warn: vi.fn(),
19
+ error: vi.fn(),
20
+ child: vi.fn().mockReturnThis()
21
+ };
22
+
23
+ return {
24
+ loggerSpies: logger,
25
+ createLoggerMock: vi.fn(() => logger),
26
+ applyDiffMock: vi.fn<(...args: any[]) => string>()
27
+ };
28
+ });
29
+
30
+ vi.mock('@repo/shared', () => ({
31
+ createLogger: createLoggerMock
32
+ }));
33
+
34
+ vi.mock('@openai/agents', () => ({
35
+ applyDiff: applyDiffMock
36
+ }));
37
+
38
+ describe('Shell', () => {
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ });
42
+
43
+ it('runs commands and collects results', async () => {
44
+ const execMock = vi.fn().mockResolvedValue({
45
+ stdout: 'hello\n',
46
+ stderr: '',
47
+ exitCode: 0
48
+ });
49
+
50
+ const mockSandbox: MockSandbox = { exec: execMock };
51
+ const shell = new Shell(mockSandbox as unknown as Sandbox);
52
+
53
+ const result = await shell.run({
54
+ commands: ['echo hello'],
55
+ timeoutMs: 500
56
+ });
57
+
58
+ expect(execMock).toHaveBeenCalledWith('echo hello', {
59
+ timeout: 500,
60
+ cwd: '/workspace'
61
+ });
62
+ expect(result.output).toHaveLength(1);
63
+ expect(result.output[0]).toMatchObject({
64
+ command: 'echo hello',
65
+ stdout: 'hello\n',
66
+ stderr: '',
67
+ outcome: { type: 'exit', exitCode: 0 }
68
+ });
69
+ expect(shell.results).toHaveLength(1);
70
+ expect(loggerSpies.info).toHaveBeenCalledWith(
71
+ 'Command completed successfully',
72
+ { command: 'echo hello' }
73
+ );
74
+ });
75
+
76
+ it('halts subsequent commands after a timeout error', async () => {
77
+ const timeoutError = new Error('Command timed out');
78
+ const execMock = vi.fn().mockRejectedValue(timeoutError);
79
+
80
+ const mockSandbox: MockSandbox = { exec: execMock };
81
+ const shell = new Shell(mockSandbox as unknown as Sandbox);
82
+ const action = {
83
+ commands: ['sleep 1', 'echo never'],
84
+ timeoutMs: 25
85
+ };
86
+
87
+ const result = await shell.run(action);
88
+
89
+ expect(execMock).toHaveBeenCalledTimes(1);
90
+ expect(result.output[0].outcome).toEqual({ type: 'timeout' });
91
+ expect(shell.results[0].exitCode).toBeNull();
92
+ expect(loggerSpies.warn).toHaveBeenCalledWith(
93
+ 'Breaking command loop due to timeout'
94
+ );
95
+ expect(loggerSpies.error).toHaveBeenCalledWith(
96
+ 'Command timed out',
97
+ undefined,
98
+ expect.objectContaining({
99
+ command: 'sleep 1',
100
+ timeout: 25
101
+ })
102
+ );
103
+ });
104
+ });
105
+
106
+ describe('Editor', () => {
107
+ beforeEach(() => {
108
+ vi.clearAllMocks();
109
+ applyDiffMock.mockReset();
110
+ });
111
+
112
+ it('creates files using applyDiff output', async () => {
113
+ applyDiffMock.mockReturnValueOnce('file contents');
114
+
115
+ const mockSandbox: MockSandbox = {
116
+ mkdir: vi.fn().mockResolvedValue(undefined),
117
+ writeFile: vi.fn().mockResolvedValue(undefined)
118
+ };
119
+
120
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
121
+ const operation = {
122
+ type: 'create_file',
123
+ path: 'src/app.ts',
124
+ diff: '--- diff ---'
125
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
126
+
127
+ await editor.createFile(operation);
128
+
129
+ expect(applyDiffMock).toHaveBeenCalledWith('', operation.diff, 'create');
130
+ expect(mockSandbox.mkdir).toHaveBeenCalledWith('/workspace/src', {
131
+ recursive: true
132
+ });
133
+ expect(mockSandbox.writeFile).toHaveBeenCalledWith(
134
+ '/workspace/src/app.ts',
135
+ 'file contents',
136
+ { encoding: 'utf-8' }
137
+ );
138
+ expect(editor.results[0]).toMatchObject({
139
+ operation: 'create',
140
+ path: 'src/app.ts',
141
+ status: 'completed'
142
+ });
143
+ expect(loggerSpies.info).toHaveBeenCalledWith(
144
+ 'File created successfully',
145
+ expect.objectContaining({ path: 'src/app.ts' })
146
+ );
147
+ });
148
+
149
+ it('applies diffs when updating existing files', async () => {
150
+ applyDiffMock.mockReturnValueOnce('patched content');
151
+
152
+ const mockSandbox: MockSandbox = {
153
+ readFile: vi.fn().mockResolvedValue({ content: 'original content' }),
154
+ writeFile: vi.fn().mockResolvedValue(undefined)
155
+ };
156
+
157
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
158
+ const operation = {
159
+ type: 'update_file',
160
+ path: 'README.md',
161
+ diff: 'patch diff'
162
+ } as Extract<ApplyPatchOperation, { type: 'update_file' }>;
163
+
164
+ await editor.updateFile(operation);
165
+
166
+ expect(mockSandbox.readFile).toHaveBeenCalledWith('/workspace/README.md', {
167
+ encoding: 'utf-8'
168
+ });
169
+ expect(applyDiffMock).toHaveBeenCalledWith(
170
+ 'original content',
171
+ operation.diff
172
+ );
173
+ expect(mockSandbox.writeFile).toHaveBeenCalledWith(
174
+ '/workspace/README.md',
175
+ 'patched content',
176
+ { encoding: 'utf-8' }
177
+ );
178
+ expect(editor.results[0]).toMatchObject({
179
+ operation: 'update',
180
+ path: 'README.md',
181
+ status: 'completed'
182
+ });
183
+ });
184
+
185
+ it('throws descriptive error when attempting to update a missing file', async () => {
186
+ const missingError = Object.assign(new Error('not found'), { status: 404 });
187
+ const mockSandbox: MockSandbox = {
188
+ readFile: vi.fn().mockRejectedValue(missingError)
189
+ };
190
+
191
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
192
+ const operation = {
193
+ type: 'update_file',
194
+ path: 'missing.txt',
195
+ diff: 'patch diff'
196
+ } as Extract<ApplyPatchOperation, { type: 'update_file' }>;
197
+
198
+ await expect(editor.updateFile(operation)).rejects.toThrow(
199
+ 'Cannot update missing file: missing.txt'
200
+ );
201
+ expect(loggerSpies.error).toHaveBeenCalledWith(
202
+ 'Cannot update missing file',
203
+ undefined,
204
+ { path: 'missing.txt' }
205
+ );
206
+ });
207
+
208
+ describe('Path traversal security', () => {
209
+ it('should reject path traversal attempts with ../', async () => {
210
+ const mockSandbox: MockSandbox = {
211
+ mkdir: vi.fn(),
212
+ writeFile: vi.fn()
213
+ };
214
+
215
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
216
+ const operation = {
217
+ type: 'create_file',
218
+ path: '../etc/passwd',
219
+ diff: 'malicious content'
220
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
221
+
222
+ await expect(editor.createFile(operation)).rejects.toThrow(
223
+ 'Operation outside workspace: ../etc/passwd'
224
+ );
225
+ expect(mockSandbox.writeFile).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('should reject path traversal attempts with ../../', async () => {
229
+ const mockSandbox: MockSandbox = {
230
+ mkdir: vi.fn(),
231
+ writeFile: vi.fn()
232
+ };
233
+
234
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
235
+ const operation = {
236
+ type: 'create_file',
237
+ path: '../../etc/passwd',
238
+ diff: 'malicious content'
239
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
240
+
241
+ await expect(editor.createFile(operation)).rejects.toThrow(
242
+ 'Operation outside workspace: ../../etc/passwd'
243
+ );
244
+ expect(mockSandbox.writeFile).not.toHaveBeenCalled();
245
+ });
246
+
247
+ it('should reject path traversal attempts with mixed paths like src/../../etc/passwd', async () => {
248
+ const mockSandbox: MockSandbox = {
249
+ mkdir: vi.fn(),
250
+ writeFile: vi.fn()
251
+ };
252
+
253
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
254
+ const operation = {
255
+ type: 'create_file',
256
+ path: 'src/../../etc/passwd',
257
+ diff: 'malicious content'
258
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
259
+
260
+ await expect(editor.createFile(operation)).rejects.toThrow(
261
+ 'Operation outside workspace: src/../../etc/passwd'
262
+ );
263
+ expect(mockSandbox.writeFile).not.toHaveBeenCalled();
264
+ });
265
+
266
+ it('should reject path traversal attempts with leading slash /../../etc/passwd', async () => {
267
+ const mockSandbox: MockSandbox = {
268
+ mkdir: vi.fn(),
269
+ writeFile: vi.fn()
270
+ };
271
+
272
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
273
+ const operation = {
274
+ type: 'create_file',
275
+ path: '/../../etc/passwd',
276
+ diff: 'malicious content'
277
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
278
+
279
+ await expect(editor.createFile(operation)).rejects.toThrow(
280
+ 'Operation outside workspace: /../../etc/passwd'
281
+ );
282
+ expect(mockSandbox.writeFile).not.toHaveBeenCalled();
283
+ });
284
+
285
+ it('should reject path traversal attempts with leading dot-slash ./../../etc/passwd', async () => {
286
+ const mockSandbox: MockSandbox = {
287
+ mkdir: vi.fn(),
288
+ writeFile: vi.fn()
289
+ };
290
+
291
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
292
+ const operation = {
293
+ type: 'create_file',
294
+ path: './../../etc/passwd',
295
+ diff: 'malicious content'
296
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
297
+
298
+ await expect(editor.createFile(operation)).rejects.toThrow(
299
+ 'Operation outside workspace: ./../../etc/passwd'
300
+ );
301
+ expect(mockSandbox.writeFile).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it('should reject path traversal in updateFile operations', async () => {
305
+ const mockSandbox: MockSandbox = {
306
+ readFile: vi.fn()
307
+ };
308
+
309
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
310
+ const operation = {
311
+ type: 'update_file',
312
+ path: '../../etc/passwd',
313
+ diff: 'patch diff'
314
+ } as Extract<ApplyPatchOperation, { type: 'update_file' }>;
315
+
316
+ await expect(editor.updateFile(operation)).rejects.toThrow(
317
+ 'Operation outside workspace: ../../etc/passwd'
318
+ );
319
+ expect(mockSandbox.readFile).not.toHaveBeenCalled();
320
+ });
321
+
322
+ it('should reject path traversal in deleteFile operations', async () => {
323
+ const mockSandbox: MockSandbox = {
324
+ deleteFile: vi.fn()
325
+ };
326
+
327
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
328
+ const operation = {
329
+ type: 'delete_file',
330
+ path: '../../etc/passwd'
331
+ } as Extract<ApplyPatchOperation, { type: 'delete_file' }>;
332
+
333
+ await expect(editor.deleteFile(operation)).rejects.toThrow(
334
+ 'Operation outside workspace: ../../etc/passwd'
335
+ );
336
+ expect(mockSandbox.deleteFile).not.toHaveBeenCalled();
337
+ });
338
+
339
+ it('should allow valid paths that use .. but stay within workspace', async () => {
340
+ applyDiffMock.mockReturnValueOnce('file contents');
341
+
342
+ const mockSandbox: MockSandbox = {
343
+ mkdir: vi.fn().mockResolvedValue(undefined),
344
+ writeFile: vi.fn().mockResolvedValue(undefined)
345
+ };
346
+
347
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
348
+ const operation = {
349
+ type: 'create_file',
350
+ path: 'src/subdir/../../file.txt',
351
+ diff: '--- diff ---'
352
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
353
+
354
+ await editor.createFile(operation);
355
+
356
+ // Should resolve to /workspace/file.txt
357
+ expect(mockSandbox.writeFile).toHaveBeenCalledWith(
358
+ '/workspace/file.txt',
359
+ 'file contents',
360
+ { encoding: 'utf-8' }
361
+ );
362
+ });
363
+
364
+ it('should handle paths with multiple consecutive slashes correctly', async () => {
365
+ applyDiffMock.mockReturnValueOnce('file contents');
366
+
367
+ const mockSandbox: MockSandbox = {
368
+ mkdir: vi.fn().mockResolvedValue(undefined),
369
+ writeFile: vi.fn().mockResolvedValue(undefined)
370
+ };
371
+
372
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
373
+ const operation = {
374
+ type: 'create_file',
375
+ path: 'src//subdir///file.txt',
376
+ diff: '--- diff ---'
377
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
378
+
379
+ await editor.createFile(operation);
380
+
381
+ expect(mockSandbox.writeFile).toHaveBeenCalledWith(
382
+ '/workspace/src/subdir/file.txt',
383
+ 'file contents',
384
+ { encoding: 'utf-8' }
385
+ );
386
+ });
387
+
388
+ it('should reject deep path traversal attempts', async () => {
389
+ const mockSandbox: MockSandbox = {
390
+ mkdir: vi.fn(),
391
+ writeFile: vi.fn()
392
+ };
393
+
394
+ const editor = new Editor(mockSandbox as unknown as Sandbox);
395
+ const operation = {
396
+ type: 'create_file',
397
+ path: 'a/b/c/../../../../etc/passwd',
398
+ diff: 'malicious content'
399
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
400
+
401
+ await expect(editor.createFile(operation)).rejects.toThrow(
402
+ 'Operation outside workspace: a/b/c/../../../../etc/passwd'
403
+ );
404
+ expect(mockSandbox.writeFile).not.toHaveBeenCalled();
405
+ });
406
+
407
+ it('should handle absolute paths within workspace', async () => {
408
+ applyDiffMock.mockReturnValueOnce('content');
409
+
410
+ const mockSandbox: MockSandbox = {
411
+ mkdir: vi.fn().mockResolvedValue(undefined),
412
+ writeFile: vi.fn().mockResolvedValue(undefined)
413
+ };
414
+
415
+ const editor = new Editor(
416
+ mockSandbox as unknown as Sandbox,
417
+ '/workspace'
418
+ );
419
+ const operation = {
420
+ type: 'create_file',
421
+ path: '/workspace/file.txt',
422
+ diff: 'content'
423
+ } as Extract<ApplyPatchOperation, { type: 'create_file' }>;
424
+
425
+ await editor.createFile(operation);
426
+
427
+ expect(mockSandbox.writeFile).toHaveBeenCalledWith(
428
+ '/workspace/file.txt', // Not /workspace/workspace/file.txt
429
+ 'content',
430
+ { encoding: 'utf-8' }
431
+ );
432
+ });
433
+ });
434
+ });