Skip to content

Commit cf35f61

Browse files
mishushakovclaude
andauthored
feat: use application/octet-stream for sandbox file uploads (#1242)
## Summary - Switches sandbox filesystem file uploads from `multipart/form-data` to `application/octet-stream` in both the JS and Python SDKs - Each file is now uploaded as raw binary with the path passed as a query parameter, matching the `application/octet-stream` content type in the envd API spec - Multi-file writes send one request per file sequentially ## Test plan - [ ] Run JS SDK filesystem write tests (`pnpm run test` in `packages/js-sdk`) - [ ] Run Python SDK filesystem write tests (`pytest` in `packages/python-sdk`) - [ ] Verify single file write, multi-file write, and various data types (string, bytes, streams) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ef46004 commit cf35f61

File tree

6 files changed

+281
-102
lines changed

6 files changed

+281
-102
lines changed

.changeset/moody-baths-dig.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@e2b/python-sdk': minor
3+
'e2b': minor
4+
---
5+
6+
switch to application/octet-stream for file uploads

packages/js-sdk/src/envd/versions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export const ENVD_DEBUG_FALLBACK = '99.99.99'
33
export const ENVD_COMMANDS_STDIN = '0.3.0'
44
export const ENVD_DEFAULT_USER = '0.4.0'
55
export const ENVD_ENVD_CLOSE = '0.5.2'
6+
export const ENVD_OCTET_STREAM_UPLOAD = '0.5.7'

packages/js-sdk/src/sandbox/filesystem/index.ts

Lines changed: 79 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type { Timestamp } from '@bufbuild/protobuf/wkt'
2929
import { compareVersions } from 'compare-versions'
3030
import {
3131
ENVD_DEFAULT_USER,
32+
ENVD_OCTET_STREAM_UPLOAD,
3233
ENVD_VERSION_RECURSIVE_WATCH,
3334
} from '../../envd/versions'
3435
import {
@@ -380,12 +381,6 @@ export class Filesystem {
380381

381382
if (writeFiles.length === 0) return [] as WriteInfo[]
382383

383-
const formData = new FormData()
384-
for (let i = 0; i < writeFiles.length; i++) {
385-
const file = writeFiles[i]
386-
formData.append('file', await toBlob(file.data), writeFiles[i].path)
387-
}
388-
389384
let user = writeOpts?.user
390385
if (
391386
user == undefined &&
@@ -394,29 +389,89 @@ export class Filesystem {
394389
user = defaultUsername
395390
}
396391

397-
const res = await this.envdApi.api.POST('/files', {
398-
params: {
399-
query: {
400-
path,
401-
username: user,
392+
const useOctetStream =
393+
compareVersions(this.envdApi.version, ENVD_OCTET_STREAM_UPLOAD) >= 0
394+
395+
const results: WriteInfo[] = []
396+
397+
if (useOctetStream) {
398+
const uploadResults = await Promise.all(
399+
writeFiles.map(async (file) => {
400+
const filePath = path ?? (file as WriteEntry).path
401+
const blob = await toBlob(file.data)
402+
403+
const res = await this.envdApi.api.POST('/files', {
404+
params: {
405+
query: {
406+
path: filePath,
407+
username: user,
408+
},
409+
},
410+
bodySerializer: () => blob,
411+
headers: {
412+
'Content-Type': 'application/octet-stream',
413+
},
414+
signal: this.connectionConfig.getSignal(
415+
writeOpts?.requestTimeoutMs
416+
),
417+
body: {},
418+
})
419+
420+
const err = await handleFilesystemEnvdApiError(res)
421+
if (err) {
422+
throw err
423+
}
424+
425+
const files = res.data as WriteInfo[]
426+
if (!files || files.length === 0) {
427+
throw new Error(
428+
'Expected to receive information about written file'
429+
)
430+
}
431+
432+
return files
433+
})
434+
)
435+
436+
for (const files of uploadResults) {
437+
results.push(...files)
438+
}
439+
} else {
440+
const formData = new FormData()
441+
for (const file of writeFiles) {
442+
formData.append(
443+
'file',
444+
await toBlob(file.data),
445+
(file as WriteEntry).path ?? path!
446+
)
447+
}
448+
449+
const res = await this.envdApi.api.POST('/files', {
450+
params: {
451+
query: {
452+
path,
453+
username: user,
454+
},
402455
},
403-
},
404-
bodySerializer: () => formData,
405-
signal: this.connectionConfig.getSignal(opts?.requestTimeoutMs),
406-
body: {},
407-
})
456+
bodySerializer: () => formData,
457+
signal: this.connectionConfig.getSignal(writeOpts?.requestTimeoutMs),
458+
body: {},
459+
})
460+
461+
const err = await handleFilesystemEnvdApiError(res)
462+
if (err) {
463+
throw err
464+
}
408465

409-
const err = await handleFilesystemEnvdApiError(res)
410-
if (err) {
411-
throw err
412-
}
466+
const files = res.data as WriteInfo[]
467+
if (!files || files.length === 0) {
468+
throw new Error('Expected to receive information about written file')
469+
}
413470

414-
const files = res.data as WriteInfo[]
415-
if (!files) {
416-
throw new Error('Expected to receive information about written file')
471+
results.push(...files)
417472
}
418473

419-
return files.length === 1 && path ? files[0] : files
474+
return results.length === 1 && path ? results[0] : results
420475
}
421476

422477
/**

packages/python-sdk/e2b/envd/versions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
ENVD_COMMANDS_STDIN = Version("0.3.0")
66
ENVD_DEFAULT_USER = Version("0.4.0")
77
ENVD_ENVD_CLOSE = Version("0.5.2")
8+
ENVD_OCTET_STREAM_UPLOAD = Version("0.5.7")

packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py

Lines changed: 101 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
from io import IOBase, TextIOBase
23
from typing import IO, AsyncIterator, List, Literal, Optional, Union, overload
34

@@ -16,7 +17,11 @@
1617
from e2b.envd.api import ENVD_API_FILES_ROUTE, ahandle_envd_api_exception
1718
from e2b.envd.filesystem import filesystem_connect, filesystem_pb2
1819
from e2b.envd.rpc import authentication_header, handle_rpc_exception
19-
from e2b.envd.versions import ENVD_DEFAULT_USER, ENVD_VERSION_RECURSIVE_WATCH
20+
from e2b.envd.versions import (
21+
ENVD_DEFAULT_USER,
22+
ENVD_OCTET_STREAM_UPLOAD,
23+
ENVD_VERSION_RECURSIVE_WATCH,
24+
)
2025
from e2b.exceptions import (
2126
FileNotFoundException,
2227
InvalidArgumentException,
@@ -223,51 +228,108 @@ async def write_files(
223228
if username is None and self._envd_version < ENVD_DEFAULT_USER:
224229
username = default_username
225230

226-
params = {}
227-
if username:
228-
params["username"] = username
229-
if len(files) == 1:
230-
params["path"] = files[0]["path"]
231-
232-
# Prepare the files for the multipart/form-data request
233-
httpx_files = []
234-
for file in files:
235-
file_path, file_data = file["path"], file["data"]
236-
if isinstance(file_data, (str, bytes)):
237-
# str and bytes can be passed directly
238-
httpx_files.append(("file", (file_path, file_data)))
239-
elif isinstance(file_data, TextIOBase):
240-
# Text streams must be read first
241-
httpx_files.append(("file", (file_path, file_data.read())))
242-
elif isinstance(file_data, IOBase):
243-
# Binary streams can be passed directly
244-
httpx_files.append(("file", (file_path, file_data)))
245-
else:
246-
raise InvalidArgumentException(
247-
f"Unsupported data type for file {file_path}"
231+
if len(files) == 0:
232+
return []
233+
234+
use_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD
235+
236+
results: List[WriteInfo] = []
237+
238+
if use_octet_stream:
239+
240+
async def _upload_file(file):
241+
file_path, file_data = file["path"], file["data"]
242+
243+
if isinstance(file_data, str):
244+
content = file_data.encode("utf-8")
245+
elif isinstance(file_data, bytes):
246+
content = file_data
247+
elif isinstance(file_data, TextIOBase):
248+
content = file_data.read().encode("utf-8")
249+
elif isinstance(file_data, IOBase):
250+
content = file_data.read()
251+
else:
252+
raise InvalidArgumentException(
253+
f"Unsupported data type for file {file_path}"
254+
)
255+
256+
params = {"path": file_path}
257+
if username:
258+
params["username"] = username
259+
260+
r = await self._envd_api.post(
261+
ENVD_API_FILES_ROUTE,
262+
content=content,
263+
headers={"Content-Type": "application/octet-stream"},
264+
params=params,
265+
timeout=self._connection_config.get_request_timeout(
266+
request_timeout
267+
),
248268
)
249269

250-
# Allow passing empty list of files
251-
if len(httpx_files) == 0:
252-
return []
270+
err = await _ahandle_filesystem_envd_api_exception(r)
271+
if err:
272+
raise err
253273

254-
r = await self._envd_api.post(
255-
ENVD_API_FILES_ROUTE,
256-
files=httpx_files,
257-
params=params,
258-
timeout=self._connection_config.get_request_timeout(request_timeout),
259-
)
274+
write_result = r.json()
260275

261-
err = await _ahandle_filesystem_envd_api_exception(r)
262-
if err:
263-
raise err
276+
if not isinstance(write_result, list) or len(write_result) == 0:
277+
raise SandboxException(
278+
"Expected to receive information about written file"
279+
)
280+
281+
return [WriteInfo(**f) for f in write_result]
282+
283+
upload_results = await asyncio.gather(
284+
*[_upload_file(file) for file in files]
285+
)
286+
for file_results in upload_results:
287+
results.extend(file_results)
288+
else:
289+
params = {}
290+
if username:
291+
params["username"] = username
292+
if len(files) == 1:
293+
params["path"] = files[0]["path"]
294+
295+
httpx_files = []
296+
for file in files:
297+
file_path, file_data = file["path"], file["data"]
298+
if isinstance(file_data, (str, bytes)):
299+
httpx_files.append(("file", (file_path, file_data)))
300+
elif isinstance(file_data, TextIOBase):
301+
httpx_files.append(("file", (file_path, file_data.read())))
302+
elif isinstance(file_data, IOBase):
303+
httpx_files.append(("file", (file_path, file_data)))
304+
else:
305+
raise InvalidArgumentException(
306+
f"Unsupported data type for file {file_path}"
307+
)
264308

265-
write_files = r.json()
309+
if len(httpx_files) == 0:
310+
return []
311+
312+
r = await self._envd_api.post(
313+
ENVD_API_FILES_ROUTE,
314+
files=httpx_files,
315+
params=params,
316+
timeout=self._connection_config.get_request_timeout(request_timeout),
317+
)
318+
319+
err = await _ahandle_filesystem_envd_api_exception(r)
320+
if err:
321+
raise err
322+
323+
write_result = r.json()
324+
325+
if not isinstance(write_result, list) or len(write_result) == 0:
326+
raise SandboxException(
327+
"Expected to receive information about written file"
328+
)
266329

267-
if not isinstance(write_files, list) or len(write_files) == 0:
268-
raise SandboxException("Expected to receive information about written file")
330+
results.extend([WriteInfo(**f) for f in write_result])
269331

270-
return [WriteInfo(**file) for file in write_files]
332+
return results
271333

272334
async def list(
273335
self,

0 commit comments

Comments
 (0)