From da3fc4d35907ee2d5702724cca1926419fc06230 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:32:43 +0200 Subject: [PATCH 1/8] feat(sdks): expose user-defined file metadata on sandbox.files Adds a metadata option to file uploads and surfaces persisted metadata on EntryInfo/WriteInfo returned by getInfo, list, rename, and write. Metadata is sent as X-Metadata- headers and persisted by envd as user.e2b.* xattrs. Syncs the envd OpenAPI spec and filesystem proto, regenerates the JS and Python clients, and adds integration tests. Requires envd 0.5.26 or later. Co-Authored-By: Claude Opus 4.8 --- .changeset/file-metadata-xattrs.md | 8 ++ .../src/envd/filesystem/filesystem_pb.ts | 10 +- packages/js-sdk/src/envd/schema.gen.ts | 15 +- packages/js-sdk/src/envd/versions.ts | 1 + .../js-sdk/src/sandbox/filesystem/index.ts | 61 ++++++++ .../tests/sandbox/files/metadata.test.ts | 136 ++++++++++++++++++ .../e2b/envd/filesystem/filesystem_pb2.py | 72 +++++----- .../e2b/envd/filesystem/filesystem_pb2.pyi | 14 ++ packages/python-sdk/e2b/envd/versions.py | 1 + .../e2b/sandbox/filesystem/filesystem.py | 29 +++- .../sandbox_async/filesystem/filesystem.py | 37 ++++- .../e2b/sandbox_sync/filesystem/filesystem.py | 37 ++++- .../sandbox_async/files/test_metadata.py | 120 ++++++++++++++++ .../sync/sandbox_sync/files/test_metadata.py | 112 +++++++++++++++ spec/envd/envd.yaml | 14 +- spec/envd/filesystem/filesystem.proto | 3 + 16 files changed, 623 insertions(+), 47 deletions(-) create mode 100644 .changeset/file-metadata-xattrs.md create mode 100644 packages/js-sdk/tests/sandbox/files/metadata.test.ts create mode 100644 packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py create mode 100644 packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py diff --git a/.changeset/file-metadata-xattrs.md b/.changeset/file-metadata-xattrs.md new file mode 100644 index 0000000000..65cf90930f --- /dev/null +++ b/.changeset/file-metadata-xattrs.md @@ -0,0 +1,8 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +feat(sdks): expose user-defined file metadata on `sandbox.files` + +Adds a `metadata` option to file uploads (`write` / `writeFiles` / `write_files`) and surfaces persisted metadata on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.` xattr namespace and returns them on subsequent filesystem reads. Keys must be US-ASCII; the same metadata map is applied to every file in a multi-file upload. Requires envd 0.5.26 or later. diff --git a/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts b/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts index 06138291c4..6bd28247f0 100644 --- a/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts +++ b/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts @@ -24,7 +24,7 @@ import type { Message } from '@bufbuild/protobuf' export const file_filesystem_filesystem: GenFile = /*@__PURE__*/ fileDesc( - 'ChtmaWxlc3lzdGVtL2ZpbGVzeXN0ZW0ucHJvdG8SCmZpbGVzeXN0ZW0iMgoLTW92ZVJlcXVlc3QSDgoGc291cmNlGAEgASgJEhMKC2Rlc3RpbmF0aW9uGAIgASgJIjQKDE1vdmVSZXNwb25zZRIkCgVlbnRyeRgBIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvIh4KDk1ha2VEaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkiNwoPTWFrZURpclJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iHQoNUmVtb3ZlUmVxdWVzdBIMCgRwYXRoGAEgASgJIhAKDlJlbW92ZVJlc3BvbnNlIhsKC1N0YXRSZXF1ZXN0EgwKBHBhdGgYASABKAkiNAoMU3RhdFJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8i/QEKCUVudHJ5SW5mbxIMCgRuYW1lGAEgASgJEiIKBHR5cGUYAiABKA4yFC5maWxlc3lzdGVtLkZpbGVUeXBlEgwKBHBhdGgYAyABKAkSDAoEc2l6ZRgEIAEoAxIMCgRtb2RlGAUgASgNEhMKC3Blcm1pc3Npb25zGAYgASgJEg0KBW93bmVyGAcgASgJEg0KBWdyb3VwGAggASgJEjEKDW1vZGlmaWVkX3RpbWUYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhsKDnN5bWxpbmtfdGFyZ2V0GAogASgJSACIAQFCEQoPX3N5bWxpbmtfdGFyZ2V0Ii0KDkxpc3REaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkSDQoFZGVwdGgYAiABKA0iOQoPTGlzdERpclJlc3BvbnNlEiYKB2VudHJpZXMYASADKAsyFS5maWxlc3lzdGVtLkVudHJ5SW5mbyIyCg9XYXRjaERpclJlcXVlc3QSDAoEcGF0aBgBIAEoCRIRCglyZWN1cnNpdmUYAiABKAgiRAoPRmlsZXN5c3RlbUV2ZW50EgwKBG5hbWUYASABKAkSIwoEdHlwZRgCIAEoDjIVLmZpbGVzeXN0ZW0uRXZlbnRUeXBlIuABChBXYXRjaERpclJlc3BvbnNlEjgKBXN0YXJ0GAEgASgLMicuZmlsZXN5c3RlbS5XYXRjaERpclJlc3BvbnNlLlN0YXJ0RXZlbnRIABIxCgpmaWxlc3lzdGVtGAIgASgLMhsuZmlsZXN5c3RlbS5GaWxlc3lzdGVtRXZlbnRIABI7CglrZWVwYWxpdmUYAyABKAsyJi5maWxlc3lzdGVtLldhdGNoRGlyUmVzcG9uc2UuS2VlcEFsaXZlSAAaDAoKU3RhcnRFdmVudBoLCglLZWVwQWxpdmVCBwoFZXZlbnQiNwoUQ3JlYXRlV2F0Y2hlclJlcXVlc3QSDAoEcGF0aBgBIAEoCRIRCglyZWN1cnNpdmUYAiABKAgiKwoVQ3JlYXRlV2F0Y2hlclJlc3BvbnNlEhIKCndhdGNoZXJfaWQYASABKAkiLQoXR2V0V2F0Y2hlckV2ZW50c1JlcXVlc3QSEgoKd2F0Y2hlcl9pZBgBIAEoCSJHChhHZXRXYXRjaGVyRXZlbnRzUmVzcG9uc2USKwoGZXZlbnRzGAEgAygLMhsuZmlsZXN5c3RlbS5GaWxlc3lzdGVtRXZlbnQiKgoUUmVtb3ZlV2F0Y2hlclJlcXVlc3QSEgoKd2F0Y2hlcl9pZBgBIAEoCSIXChVSZW1vdmVXYXRjaGVyUmVzcG9uc2UqUgoIRmlsZVR5cGUSGQoVRklMRV9UWVBFX1VOU1BFQ0lGSUVEEAASEgoORklMRV9UWVBFX0ZJTEUQARIXChNGSUxFX1RZUEVfRElSRUNUT1JZEAIqmAEKCUV2ZW50VHlwZRIaChZFVkVOVF9UWVBFX1VOU1BFQ0lGSUVEEAASFQoRRVZFTlRfVFlQRV9DUkVBVEUQARIUChBFVkVOVF9UWVBFX1dSSVRFEAISFQoRRVZFTlRfVFlQRV9SRU1PVkUQAxIVChFFVkVOVF9UWVBFX1JFTkFNRRAEEhQKEEVWRU5UX1RZUEVfQ0hNT0QQBTKfBQoKRmlsZXN5c3RlbRI5CgRTdGF0EhcuZmlsZXN5c3RlbS5TdGF0UmVxdWVzdBoYLmZpbGVzeXN0ZW0uU3RhdFJlc3BvbnNlEkIKB01ha2VEaXISGi5maWxlc3lzdGVtLk1ha2VEaXJSZXF1ZXN0GhsuZmlsZXN5c3RlbS5NYWtlRGlyUmVzcG9uc2USOQoETW92ZRIXLmZpbGVzeXN0ZW0uTW92ZVJlcXVlc3QaGC5maWxlc3lzdGVtLk1vdmVSZXNwb25zZRJCCgdMaXN0RGlyEhouZmlsZXN5c3RlbS5MaXN0RGlyUmVxdWVzdBobLmZpbGVzeXN0ZW0uTGlzdERpclJlc3BvbnNlEj8KBlJlbW92ZRIZLmZpbGVzeXN0ZW0uUmVtb3ZlUmVxdWVzdBoaLmZpbGVzeXN0ZW0uUmVtb3ZlUmVzcG9uc2USRwoIV2F0Y2hEaXISGy5maWxlc3lzdGVtLldhdGNoRGlyUmVxdWVzdBocLmZpbGVzeXN0ZW0uV2F0Y2hEaXJSZXNwb25zZTABElQKDUNyZWF0ZVdhdGNoZXISIC5maWxlc3lzdGVtLkNyZWF0ZVdhdGNoZXJSZXF1ZXN0GiEuZmlsZXN5c3RlbS5DcmVhdGVXYXRjaGVyUmVzcG9uc2USXQoQR2V0V2F0Y2hlckV2ZW50cxIjLmZpbGVzeXN0ZW0uR2V0V2F0Y2hlckV2ZW50c1JlcXVlc3QaJC5maWxlc3lzdGVtLkdldFdhdGNoZXJFdmVudHNSZXNwb25zZRJUCg1SZW1vdmVXYXRjaGVyEiAuZmlsZXN5c3RlbS5SZW1vdmVXYXRjaGVyUmVxdWVzdBohLmZpbGVzeXN0ZW0uUmVtb3ZlV2F0Y2hlclJlc3BvbnNlQmkKDmNvbS5maWxlc3lzdGVtQg9GaWxlc3lzdGVtUHJvdG9QAaICA0ZYWKoCCkZpbGVzeXN0ZW3KAgpGaWxlc3lzdGVt4gIWRmlsZXN5c3RlbVxHUEJNZXRhZGF0YeoCCkZpbGVzeXN0ZW1iBnByb3RvMw', + 'ChtmaWxlc3lzdGVtL2ZpbGVzeXN0ZW0ucHJvdG8SCmZpbGVzeXN0ZW0iMgoLTW92ZVJlcXVlc3QSDgoGc291cmNlGAEgASgJEhMKC2Rlc3RpbmF0aW9uGAIgASgJIjQKDE1vdmVSZXNwb25zZRIkCgVlbnRyeRgBIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvIh4KDk1ha2VEaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkiNwoPTWFrZURpclJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iHQoNUmVtb3ZlUmVxdWVzdBIMCgRwYXRoGAEgASgJIhAKDlJlbW92ZVJlc3BvbnNlIhsKC1N0YXRSZXF1ZXN0EgwKBHBhdGgYASABKAkiNAoMU3RhdFJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8i5QIKCUVudHJ5SW5mbxIMCgRuYW1lGAEgASgJEiIKBHR5cGUYAiABKA4yFC5maWxlc3lzdGVtLkZpbGVUeXBlEgwKBHBhdGgYAyABKAkSDAoEc2l6ZRgEIAEoAxIMCgRtb2RlGAUgASgNEhMKC3Blcm1pc3Npb25zGAYgASgJEg0KBW93bmVyGAcgASgJEg0KBWdyb3VwGAggASgJEjEKDW1vZGlmaWVkX3RpbWUYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhsKDnN5bWxpbmtfdGFyZ2V0GAogASgJSACIAQESNQoIbWV0YWRhdGEYCyADKAsyIy5maWxlc3lzdGVtLkVudHJ5SW5mby5NZXRhZGF0YUVudHJ5Gi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIRCg9fc3ltbGlua190YXJnZXQiLQoOTGlzdERpclJlcXVlc3QSDAoEcGF0aBgBIAEoCRINCgVkZXB0aBgCIAEoDSI5Cg9MaXN0RGlyUmVzcG9uc2USJgoHZW50cmllcxgBIAMoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvIjIKD1dhdGNoRGlyUmVxdWVzdBIMCgRwYXRoGAEgASgJEhEKCXJlY3Vyc2l2ZRgCIAEoCCJECg9GaWxlc3lzdGVtRXZlbnQSDAoEbmFtZRgBIAEoCRIjCgR0eXBlGAIgASgOMhUuZmlsZXN5c3RlbS5FdmVudFR5cGUi4AEKEFdhdGNoRGlyUmVzcG9uc2USOAoFc3RhcnQYASABKAsyJy5maWxlc3lzdGVtLldhdGNoRGlyUmVzcG9uc2UuU3RhcnRFdmVudEgAEjEKCmZpbGVzeXN0ZW0YAiABKAsyGy5maWxlc3lzdGVtLkZpbGVzeXN0ZW1FdmVudEgAEjsKCWtlZXBhbGl2ZRgDIAEoCzImLmZpbGVzeXN0ZW0uV2F0Y2hEaXJSZXNwb25zZS5LZWVwQWxpdmVIABoMCgpTdGFydEV2ZW50GgsKCUtlZXBBbGl2ZUIHCgVldmVudCI3ChRDcmVhdGVXYXRjaGVyUmVxdWVzdBIMCgRwYXRoGAEgASgJEhEKCXJlY3Vyc2l2ZRgCIAEoCCIrChVDcmVhdGVXYXRjaGVyUmVzcG9uc2USEgoKd2F0Y2hlcl9pZBgBIAEoCSItChdHZXRXYXRjaGVyRXZlbnRzUmVxdWVzdBISCgp3YXRjaGVyX2lkGAEgASgJIkcKGEdldFdhdGNoZXJFdmVudHNSZXNwb25zZRIrCgZldmVudHMYASADKAsyGy5maWxlc3lzdGVtLkZpbGVzeXN0ZW1FdmVudCIqChRSZW1vdmVXYXRjaGVyUmVxdWVzdBISCgp3YXRjaGVyX2lkGAEgASgJIhcKFVJlbW92ZVdhdGNoZXJSZXNwb25zZSpSCghGaWxlVHlwZRIZChVGSUxFX1RZUEVfVU5TUEVDSUZJRUQQABISCg5GSUxFX1RZUEVfRklMRRABEhcKE0ZJTEVfVFlQRV9ESVJFQ1RPUlkQAiqYAQoJRXZlbnRUeXBlEhoKFkVWRU5UX1RZUEVfVU5TUEVDSUZJRUQQABIVChFFVkVOVF9UWVBFX0NSRUFURRABEhQKEEVWRU5UX1RZUEVfV1JJVEUQAhIVChFFVkVOVF9UWVBFX1JFTU9WRRADEhUKEUVWRU5UX1RZUEVfUkVOQU1FEAQSFAoQRVZFTlRfVFlQRV9DSE1PRBAFMp8FCgpGaWxlc3lzdGVtEjkKBFN0YXQSFy5maWxlc3lzdGVtLlN0YXRSZXF1ZXN0GhguZmlsZXN5c3RlbS5TdGF0UmVzcG9uc2USQgoHTWFrZURpchIaLmZpbGVzeXN0ZW0uTWFrZURpclJlcXVlc3QaGy5maWxlc3lzdGVtLk1ha2VEaXJSZXNwb25zZRI5CgRNb3ZlEhcuZmlsZXN5c3RlbS5Nb3ZlUmVxdWVzdBoYLmZpbGVzeXN0ZW0uTW92ZVJlc3BvbnNlEkIKB0xpc3REaXISGi5maWxlc3lzdGVtLkxpc3REaXJSZXF1ZXN0GhsuZmlsZXN5c3RlbS5MaXN0RGlyUmVzcG9uc2USPwoGUmVtb3ZlEhkuZmlsZXN5c3RlbS5SZW1vdmVSZXF1ZXN0GhouZmlsZXN5c3RlbS5SZW1vdmVSZXNwb25zZRJHCghXYXRjaERpchIbLmZpbGVzeXN0ZW0uV2F0Y2hEaXJSZXF1ZXN0GhwuZmlsZXN5c3RlbS5XYXRjaERpclJlc3BvbnNlMAESVAoNQ3JlYXRlV2F0Y2hlchIgLmZpbGVzeXN0ZW0uQ3JlYXRlV2F0Y2hlclJlcXVlc3QaIS5maWxlc3lzdGVtLkNyZWF0ZVdhdGNoZXJSZXNwb25zZRJdChBHZXRXYXRjaGVyRXZlbnRzEiMuZmlsZXN5c3RlbS5HZXRXYXRjaGVyRXZlbnRzUmVxdWVzdBokLmZpbGVzeXN0ZW0uR2V0V2F0Y2hlckV2ZW50c1Jlc3BvbnNlElQKDVJlbW92ZVdhdGNoZXISIC5maWxlc3lzdGVtLlJlbW92ZVdhdGNoZXJSZXF1ZXN0GiEuZmlsZXN5c3RlbS5SZW1vdmVXYXRjaGVyUmVzcG9uc2VCaQoOY29tLmZpbGVzeXN0ZW1CD0ZpbGVzeXN0ZW1Qcm90b1ABogIDRlhYqgIKRmlsZXN5c3RlbcoCCkZpbGVzeXN0ZW3iAhZGaWxlc3lzdGVtXEdQQk1ldGFkYXRh6gIKRmlsZXN5c3RlbWIGcHJvdG8z', [file_google_protobuf_timestamp] ) @@ -227,6 +227,14 @@ export type EntryInfo = Message<'filesystem.EntryInfo'> & { * @generated from field: optional string symlink_target = 10; */ symlinkTarget?: string + + /** + * User-defined metadata stored as extended attributes (xattrs) on the file. + * Keys live in the `user.` xattr namespace; the prefix is stripped here. + * + * @generated from field: map metadata = 11; + */ + metadata: { [key: string]: string } } /** diff --git a/packages/js-sdk/src/envd/schema.gen.ts b/packages/js-sdk/src/envd/schema.gen.ts index 9c39a43d3e..72d8aec5b3 100644 --- a/packages/js-sdk/src/envd/schema.gen.ts +++ b/packages/js-sdk/src/envd/schema.gen.ts @@ -74,7 +74,16 @@ export interface paths { }; }; put?: never; - /** Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. */ + /** + * Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. + * @description Any request header of the form `X-Metadata-: ` is persisted + * as a user-defined extended attribute on the uploaded file. Keys are + * lowercased and the `X-Metadata-` prefix is stripped; the resulting + * metadata is returned on `EntryInfo` lookups (e.g. `Stat`, `ListDir`). + * Header values must be US-ASCII. Multiple files in a single multipart + * upload receive the same metadata. + * + */ post: { parameters: { query?: { @@ -234,6 +243,10 @@ export type webhooks = Record; export interface components { schemas: { EntryInfo: { + /** @description User-defined metadata stored as extended attributes on the file. */ + metadata?: { + [key: string]: string; + }; /** @description Name of the file */ name: string; /** @description Path to the file */ diff --git a/packages/js-sdk/src/envd/versions.ts b/packages/js-sdk/src/envd/versions.ts index 6348ff675e..8ef8a0e6ab 100644 --- a/packages/js-sdk/src/envd/versions.ts +++ b/packages/js-sdk/src/envd/versions.ts @@ -4,3 +4,4 @@ export const ENVD_COMMANDS_STDIN = '0.3.0' export const ENVD_DEFAULT_USER = '0.4.0' export const ENVD_ENVD_CLOSE = '0.5.2' export const ENVD_OCTET_STREAM_UPLOAD = '0.5.7' +export const ENVD_FILE_METADATA = '0.5.26' diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 14e7a2e944..9a3f5978cc 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -30,6 +30,7 @@ import type { Timestamp } from '@bufbuild/protobuf/wkt' import { compareVersions } from 'compare-versions' import { ENVD_DEFAULT_USER, + ENVD_FILE_METADATA, ENVD_OCTET_STREAM_UPLOAD, ENVD_VERSION_RECURSIVE_WATCH, } from '../../envd/versions' @@ -77,6 +78,12 @@ export interface WriteInfo { * Path to the filesystem object. */ path: string + /** + * User-defined metadata persisted on the file as extended attributes. + * Only populated when metadata was supplied on upload and the sandbox's + * envd supports it. `undefined` when no metadata is set. + */ + metadata?: Record } export interface EntryInfo extends WriteInfo { @@ -153,6 +160,26 @@ function mapModifiedTime(modifiedTime: Timestamp | undefined) { ) } +function mapMetadata( + metadata: Record | undefined +): Record | undefined { + if (!metadata) return undefined + return Object.keys(metadata).length === 0 ? undefined : metadata +} + +const METADATA_HEADER_PREFIX = 'X-Metadata-' + +function metadataHeaders( + metadata: Record | undefined +): Record { + if (!metadata) return {} + const headers: Record = {} + for (const [key, value] of Object.entries(metadata)) { + headers[`${METADATA_HEADER_PREFIX}${key}`] = value + } + return headers +} + /** * Options for the sandbox filesystem operations. */ @@ -180,6 +207,14 @@ export interface FilesystemWriteOpts extends FilesystemRequestOpts { * the sandbox's envd version, the upload falls back to `multipart/form-data`. */ useOctetStream?: boolean + /** + * User-defined metadata to persist on the uploaded file(s) as extended + * attributes. Keys and values must be printable US-ASCII and keys are + * lowercased by the sandbox, so they may differ in case when read back. + * The same metadata is applied to every file in a multi-file upload. + * Requires envd 0.5.26 or later. + */ + metadata?: Record } /** @@ -431,6 +466,19 @@ export class Filesystem { const useOctetStream = (writeOpts?.useOctetStream ?? false) && supportsOctetStream + const metadata = writeOpts?.metadata + if ( + metadata && + Object.keys(metadata).length > 0 && + compareVersions(this.envdApi.version, ENVD_FILE_METADATA) < 0 + ) { + throw new TemplateError( + 'File metadata requires envd 0.5.26 or later. ' + + 'You can update the template by running `e2b template build` in the directory with the template.' + ) + } + const extraHeaders = metadataHeaders(metadata) + const results: WriteInfo[] = [] const useGzip = writeOpts?.gzip === true @@ -438,6 +486,7 @@ export class Filesystem { if (useOctetStream) { const headers: Record = { 'Content-Type': 'application/octet-stream', + ...extraHeaders, } if (useGzip) { headers['Content-Encoding'] = 'gzip' @@ -476,6 +525,10 @@ export class Filesystem { ) } + for (const f of files) { + f.metadata = mapMetadata(f.metadata) + } + return files }) ) @@ -501,6 +554,7 @@ export class Filesystem { }, }, bodySerializer: () => formData, + headers: extraHeaders, signal: this.connectionConfig.getSignal( writeOpts?.requestTimeoutMs, writeOpts?.signal @@ -518,6 +572,10 @@ export class Filesystem { throw new Error('Expected to receive information about written file') } + for (const f of files) { + f.metadata = mapMetadata(f.metadata) + } + results.push(...files) } @@ -591,6 +649,7 @@ export class Filesystem { group: e.group, modifiedTime: mapModifiedTime(e.modifiedTime), symlinkTarget: e.symlinkTarget, + metadata: mapMetadata(e.metadata), }) } } @@ -679,6 +738,7 @@ export class Filesystem { group: entry.group, modifiedTime: mapModifiedTime(entry.modifiedTime), symlinkTarget: entry.symlinkTarget, + metadata: mapMetadata(entry.metadata), } } catch (err) { throw handleFilesystemRpcError(err) @@ -782,6 +842,7 @@ export class Filesystem { group: res.entry.group, modifiedTime: mapModifiedTime(res.entry.modifiedTime), symlinkTarget: res.entry.symlinkTarget, + metadata: mapMetadata(res.entry.metadata), } } catch (err) { throw handleFilesystemRpcError(err) diff --git a/packages/js-sdk/tests/sandbox/files/metadata.test.ts b/packages/js-sdk/tests/sandbox/files/metadata.test.ts new file mode 100644 index 0000000000..3ebd91698e --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/metadata.test.ts @@ -0,0 +1,136 @@ +import { assert } from 'vitest' + +import { WriteEntry } from '../../../src/sandbox/filesystem' +import { isDebug, sandboxTest } from '../../setup.js' + +sandboxTest('write file with metadata', async ({ sandbox }) => { + const filename = 'test_metadata.txt' + const content = 'This is a test file with metadata.' + const metadata = { author: 'mish', purpose: 'upload' } + + const info = await sandbox.files.write(filename, content, { metadata }) + assert.isFalse(Array.isArray(info)) + assert.deepEqual(info.metadata, metadata) + + // Metadata is persisted and surfaced on subsequent reads. + const stat = await sandbox.files.getInfo(filename) + assert.deepEqual(stat.metadata, metadata) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest( + 'write file with metadata using octet-stream', + async ({ sandbox }) => { + const filename = 'test_metadata_octet.txt' + const content = 'This is a test file with metadata.' + const metadata = { author: 'mish', purpose: 'upload' } + + const info = await sandbox.files.write(filename, content, { + metadata, + useOctetStream: true, + }) + assert.deepEqual(info.metadata, metadata) + + const stat = await sandbox.files.getInfo(filename) + assert.deepEqual(stat.metadata, metadata) + + if (isDebug) { + await sandbox.files.remove(filename) + } + } +) + +sandboxTest('write file without metadata', async ({ sandbox }) => { + const filename = 'test_no_metadata.txt' + + const info = await sandbox.files.write(filename, 'no metadata here') + assert.isUndefined(info.metadata) + + const stat = await sandbox.files.getInfo(filename) + assert.isUndefined(stat.metadata) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest( + 'writeFiles applies metadata to every file', + async ({ sandbox }) => { + const metadata = { source: 'test-suite' } + const files: WriteEntry[] = [ + { path: 'metadata_multi_1.txt', data: 'File 1' }, + { path: 'metadata_multi_2.txt', data: 'File 2' }, + ] + + const infos = await sandbox.files.writeFiles(files, { metadata }) + assert.equal(infos.length, files.length) + + for (const info of infos) { + assert.deepEqual(info.metadata, metadata) + + const stat = await sandbox.files.getInfo(info.path) + assert.deepEqual(stat.metadata, metadata) + } + + if (isDebug) { + for (const file of files) { + await sandbox.files.remove(file.path) + } + } + } +) + +sandboxTest('metadata is surfaced when listing', async ({ sandbox }) => { + const dirname = 'metadata_list_dir' + const filename = 'listed.txt' + const metadata = { tag: 'listed' } + + await sandbox.files.makeDir(dirname) + await sandbox.files.write(`${dirname}/${filename}`, 'content', { metadata }) + + const entries = await sandbox.files.list(dirname) + const entry = entries.find((e) => e.name === filename) + assert.isDefined(entry) + assert.deepEqual(entry?.metadata, metadata) + + if (isDebug) { + await sandbox.files.remove(dirname) + } +}) + +sandboxTest('metadata is surfaced after rename', async ({ sandbox }) => { + const oldPath = 'metadata_rename_old.txt' + const newPath = 'metadata_rename_new.txt' + const metadata = { stage: 'renamed' } + + await sandbox.files.write(oldPath, 'content', { metadata }) + const info = await sandbox.files.rename(oldPath, newPath) + assert.deepEqual(info.metadata, metadata) + + if (isDebug) { + await sandbox.files.remove(newPath) + } +}) + +sandboxTest('overwriting a file clears stale metadata', async ({ sandbox }) => { + const filename = 'metadata_overwrite.txt' + + await sandbox.files.write(filename, 'first', { + metadata: { author: 'mish' }, + }) + + // Overwriting without metadata removes the previously stored metadata. + const info = await sandbox.files.write(filename, 'second') + assert.isUndefined(info.metadata) + + const stat = await sandbox.files.getInfo(filename) + assert.isUndefined(stat.metadata) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) diff --git a/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.py b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.py index 54bb90c496..9d9a394289 100644 --- a/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.py +++ b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.py @@ -15,7 +15,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66ilesystem/filesystem.proto\x12\nfilesystem\x1a\x1fgoogle/protobuf/timestamp.proto\"G\n\x0bMoveRequest\x12\x16\n\x06source\x18\x01 \x01(\tR\x06source\x12 \n\x0b\x64\x65stination\x18\x02 \x01(\tR\x0b\x64\x65stination\";\n\x0cMoveResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"$\n\x0eMakeDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\">\n\x0fMakeDirResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"#\n\rRemoveRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\"\x10\n\x0eRemoveResponse\"!\n\x0bStatRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\";\n\x0cStatResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"\xd3\x02\n\tEntryInfo\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12(\n\x04type\x18\x02 \x01(\x0e\x32\x14.filesystem.FileTypeR\x04type\x12\x12\n\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n\x0bpermissions\x18\x06 \x01(\tR\x0bpermissions\x12\x14\n\x05owner\x18\x07 \x01(\tR\x05owner\x12\x14\n\x05group\x18\x08 \x01(\tR\x05group\x12?\n\rmodified_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0cmodifiedTime\x12*\n\x0esymlink_target\x18\n \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01\x42\x11\n\x0f_symlink_target\":\n\x0eListDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n\x05\x64\x65pth\x18\x02 \x01(\rR\x05\x64\x65pth\"B\n\x0fListDirResponse\x12/\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x15.filesystem.EntryInfoR\x07\x65ntries\"C\n\x0fWatchDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\"P\n\x0f\x46ilesystemEvent\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12)\n\x04type\x18\x02 \x01(\x0e\x32\x15.filesystem.EventTypeR\x04type\"\xfe\x01\n\x10WatchDirResponse\x12?\n\x05start\x18\x01 \x01(\x0b\x32\'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n\nfilesystem\x18\x02 \x01(\x0b\x32\x1b.filesystem.FilesystemEventH\x00R\nfilesystem\x12\x46\n\tkeepalive\x18\x03 \x01(\x0b\x32&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\x0c\n\nStartEvent\x1a\x0b\n\tKeepAliveB\x07\n\x05\x65vent\"H\n\x14\x43reateWatcherRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\"6\n\x15\x43reateWatcherResponse\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"8\n\x17GetWatcherEventsRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"O\n\x18GetWatcherEventsResponse\x12\x33\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1b.filesystem.FilesystemEventR\x06\x65vents\"5\n\x14RemoveWatcherRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"\x17\n\x15RemoveWatcherResponse*R\n\x08\x46ileType\x12\x19\n\x15\x46ILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x46ILE_TYPE_FILE\x10\x01\x12\x17\n\x13\x46ILE_TYPE_DIRECTORY\x10\x02*\x98\x01\n\tEventType\x12\x1a\n\x16\x45VENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11\x45VENT_TYPE_CREATE\x10\x01\x12\x14\n\x10\x45VENT_TYPE_WRITE\x10\x02\x12\x15\n\x11\x45VENT_TYPE_REMOVE\x10\x03\x12\x15\n\x11\x45VENT_TYPE_RENAME\x10\x04\x12\x14\n\x10\x45VENT_TYPE_CHMOD\x10\x05\x32\x9f\x05\n\nFilesystem\x12\x39\n\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12\x42\n\x07MakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x12\x39\n\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12\x42\n\x07ListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n\x08WatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseBi\n\x0e\x63om.filesystemB\x0f\x46ilesystemProtoP\x01\xa2\x02\x03\x46XX\xaa\x02\nFilesystem\xca\x02\nFilesystem\xe2\x02\x16\x46ilesystem\\GPBMetadata\xea\x02\nFilesystemb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66ilesystem/filesystem.proto\x12\nfilesystem\x1a\x1fgoogle/protobuf/timestamp.proto\"G\n\x0bMoveRequest\x12\x16\n\x06source\x18\x01 \x01(\tR\x06source\x12 \n\x0b\x64\x65stination\x18\x02 \x01(\tR\x0b\x64\x65stination\";\n\x0cMoveResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"$\n\x0eMakeDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\">\n\x0fMakeDirResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"#\n\rRemoveRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\"\x10\n\x0eRemoveResponse\"!\n\x0bStatRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\";\n\x0cStatResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"\xd1\x03\n\tEntryInfo\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12(\n\x04type\x18\x02 \x01(\x0e\x32\x14.filesystem.FileTypeR\x04type\x12\x12\n\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n\x0bpermissions\x18\x06 \x01(\tR\x0bpermissions\x12\x14\n\x05owner\x18\x07 \x01(\tR\x05owner\x12\x14\n\x05group\x18\x08 \x01(\tR\x05group\x12?\n\rmodified_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0cmodifiedTime\x12*\n\x0esymlink_target\x18\n \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01\x12?\n\x08metadata\x18\x0b \x03(\x0b\x32#.filesystem.EntryInfo.MetadataEntryR\x08metadata\x1a;\n\rMetadataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x11\n\x0f_symlink_target\":\n\x0eListDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n\x05\x64\x65pth\x18\x02 \x01(\rR\x05\x64\x65pth\"B\n\x0fListDirResponse\x12/\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x15.filesystem.EntryInfoR\x07\x65ntries\"C\n\x0fWatchDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\"P\n\x0f\x46ilesystemEvent\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12)\n\x04type\x18\x02 \x01(\x0e\x32\x15.filesystem.EventTypeR\x04type\"\xfe\x01\n\x10WatchDirResponse\x12?\n\x05start\x18\x01 \x01(\x0b\x32\'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n\nfilesystem\x18\x02 \x01(\x0b\x32\x1b.filesystem.FilesystemEventH\x00R\nfilesystem\x12\x46\n\tkeepalive\x18\x03 \x01(\x0b\x32&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\x0c\n\nStartEvent\x1a\x0b\n\tKeepAliveB\x07\n\x05\x65vent\"H\n\x14\x43reateWatcherRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\"6\n\x15\x43reateWatcherResponse\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"8\n\x17GetWatcherEventsRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"O\n\x18GetWatcherEventsResponse\x12\x33\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1b.filesystem.FilesystemEventR\x06\x65vents\"5\n\x14RemoveWatcherRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"\x17\n\x15RemoveWatcherResponse*R\n\x08\x46ileType\x12\x19\n\x15\x46ILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x46ILE_TYPE_FILE\x10\x01\x12\x17\n\x13\x46ILE_TYPE_DIRECTORY\x10\x02*\x98\x01\n\tEventType\x12\x1a\n\x16\x45VENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11\x45VENT_TYPE_CREATE\x10\x01\x12\x14\n\x10\x45VENT_TYPE_WRITE\x10\x02\x12\x15\n\x11\x45VENT_TYPE_REMOVE\x10\x03\x12\x15\n\x11\x45VENT_TYPE_RENAME\x10\x04\x12\x14\n\x10\x45VENT_TYPE_CHMOD\x10\x05\x32\x9f\x05\n\nFilesystem\x12\x39\n\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12\x42\n\x07MakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x12\x39\n\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12\x42\n\x07ListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n\x08WatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseBi\n\x0e\x63om.filesystemB\x0f\x46ilesystemProtoP\x01\xa2\x02\x03\x46XX\xaa\x02\nFilesystem\xca\x02\nFilesystem\xe2\x02\x16\x46ilesystem\\GPBMetadata\xea\x02\nFilesystemb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -23,10 +23,12 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\016com.filesystemB\017FilesystemProtoP\001\242\002\003FXX\252\002\nFilesystem\312\002\nFilesystem\342\002\026Filesystem\\GPBMetadata\352\002\nFilesystem' - _globals['_FILETYPE']._serialized_start=1690 - _globals['_FILETYPE']._serialized_end=1772 - _globals['_EVENTTYPE']._serialized_start=1775 - _globals['_EVENTTYPE']._serialized_end=1927 + _globals['_ENTRYINFO_METADATAENTRY']._loaded_options = None + _globals['_ENTRYINFO_METADATAENTRY']._serialized_options = b'8\001' + _globals['_FILETYPE']._serialized_start=1816 + _globals['_FILETYPE']._serialized_end=1898 + _globals['_EVENTTYPE']._serialized_start=1901 + _globals['_EVENTTYPE']._serialized_end=2053 _globals['_MOVEREQUEST']._serialized_start=76 _globals['_MOVEREQUEST']._serialized_end=147 _globals['_MOVERESPONSE']._serialized_start=149 @@ -44,33 +46,35 @@ _globals['_STATRESPONSE']._serialized_start=402 _globals['_STATRESPONSE']._serialized_end=461 _globals['_ENTRYINFO']._serialized_start=464 - _globals['_ENTRYINFO']._serialized_end=803 - _globals['_LISTDIRREQUEST']._serialized_start=805 - _globals['_LISTDIRREQUEST']._serialized_end=863 - _globals['_LISTDIRRESPONSE']._serialized_start=865 - _globals['_LISTDIRRESPONSE']._serialized_end=931 - _globals['_WATCHDIRREQUEST']._serialized_start=933 - _globals['_WATCHDIRREQUEST']._serialized_end=1000 - _globals['_FILESYSTEMEVENT']._serialized_start=1002 - _globals['_FILESYSTEMEVENT']._serialized_end=1082 - _globals['_WATCHDIRRESPONSE']._serialized_start=1085 - _globals['_WATCHDIRRESPONSE']._serialized_end=1339 - _globals['_WATCHDIRRESPONSE_STARTEVENT']._serialized_start=1305 - _globals['_WATCHDIRRESPONSE_STARTEVENT']._serialized_end=1317 - _globals['_WATCHDIRRESPONSE_KEEPALIVE']._serialized_start=1319 - _globals['_WATCHDIRRESPONSE_KEEPALIVE']._serialized_end=1330 - _globals['_CREATEWATCHERREQUEST']._serialized_start=1341 - _globals['_CREATEWATCHERREQUEST']._serialized_end=1413 - _globals['_CREATEWATCHERRESPONSE']._serialized_start=1415 - _globals['_CREATEWATCHERRESPONSE']._serialized_end=1469 - _globals['_GETWATCHEREVENTSREQUEST']._serialized_start=1471 - _globals['_GETWATCHEREVENTSREQUEST']._serialized_end=1527 - _globals['_GETWATCHEREVENTSRESPONSE']._serialized_start=1529 - _globals['_GETWATCHEREVENTSRESPONSE']._serialized_end=1608 - _globals['_REMOVEWATCHERREQUEST']._serialized_start=1610 - _globals['_REMOVEWATCHERREQUEST']._serialized_end=1663 - _globals['_REMOVEWATCHERRESPONSE']._serialized_start=1665 - _globals['_REMOVEWATCHERRESPONSE']._serialized_end=1688 - _globals['_FILESYSTEM']._serialized_start=1930 - _globals['_FILESYSTEM']._serialized_end=2601 + _globals['_ENTRYINFO']._serialized_end=929 + _globals['_ENTRYINFO_METADATAENTRY']._serialized_start=851 + _globals['_ENTRYINFO_METADATAENTRY']._serialized_end=910 + _globals['_LISTDIRREQUEST']._serialized_start=931 + _globals['_LISTDIRREQUEST']._serialized_end=989 + _globals['_LISTDIRRESPONSE']._serialized_start=991 + _globals['_LISTDIRRESPONSE']._serialized_end=1057 + _globals['_WATCHDIRREQUEST']._serialized_start=1059 + _globals['_WATCHDIRREQUEST']._serialized_end=1126 + _globals['_FILESYSTEMEVENT']._serialized_start=1128 + _globals['_FILESYSTEMEVENT']._serialized_end=1208 + _globals['_WATCHDIRRESPONSE']._serialized_start=1211 + _globals['_WATCHDIRRESPONSE']._serialized_end=1465 + _globals['_WATCHDIRRESPONSE_STARTEVENT']._serialized_start=1431 + _globals['_WATCHDIRRESPONSE_STARTEVENT']._serialized_end=1443 + _globals['_WATCHDIRRESPONSE_KEEPALIVE']._serialized_start=1445 + _globals['_WATCHDIRRESPONSE_KEEPALIVE']._serialized_end=1456 + _globals['_CREATEWATCHERREQUEST']._serialized_start=1467 + _globals['_CREATEWATCHERREQUEST']._serialized_end=1539 + _globals['_CREATEWATCHERRESPONSE']._serialized_start=1541 + _globals['_CREATEWATCHERRESPONSE']._serialized_end=1595 + _globals['_GETWATCHEREVENTSREQUEST']._serialized_start=1597 + _globals['_GETWATCHEREVENTSREQUEST']._serialized_end=1653 + _globals['_GETWATCHEREVENTSRESPONSE']._serialized_start=1655 + _globals['_GETWATCHEREVENTSRESPONSE']._serialized_end=1734 + _globals['_REMOVEWATCHERREQUEST']._serialized_start=1736 + _globals['_REMOVEWATCHERREQUEST']._serialized_end=1789 + _globals['_REMOVEWATCHERRESPONSE']._serialized_start=1791 + _globals['_REMOVEWATCHERRESPONSE']._serialized_end=1814 + _globals['_FILESYSTEM']._serialized_start=2056 + _globals['_FILESYSTEM']._serialized_end=2727 # @@protoc_insertion_point(module_scope) diff --git a/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.pyi b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.pyi index 4770979526..c63dd9ed06 100644 --- a/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.pyi +++ b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.pyi @@ -100,7 +100,18 @@ class EntryInfo(_message.Message): "group", "modified_time", "symlink_target", + "metadata", ) + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__( + self, key: _Optional[str] = ..., value: _Optional[str] = ... + ) -> None: ... + NAME_FIELD_NUMBER: _ClassVar[int] TYPE_FIELD_NUMBER: _ClassVar[int] PATH_FIELD_NUMBER: _ClassVar[int] @@ -111,6 +122,7 @@ class EntryInfo(_message.Message): GROUP_FIELD_NUMBER: _ClassVar[int] MODIFIED_TIME_FIELD_NUMBER: _ClassVar[int] SYMLINK_TARGET_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] name: str type: FileType path: str @@ -121,6 +133,7 @@ class EntryInfo(_message.Message): group: str modified_time: _timestamp_pb2.Timestamp symlink_target: str + metadata: _containers.ScalarMap[str, str] def __init__( self, name: _Optional[str] = ..., @@ -133,6 +146,7 @@ class EntryInfo(_message.Message): group: _Optional[str] = ..., modified_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., symlink_target: _Optional[str] = ..., + metadata: _Optional[_Mapping[str, str]] = ..., ) -> None: ... class ListDirRequest(_message.Message): diff --git a/packages/python-sdk/e2b/envd/versions.py b/packages/python-sdk/e2b/envd/versions.py index 94e9e238c5..b3ade1b0a8 100644 --- a/packages/python-sdk/e2b/envd/versions.py +++ b/packages/python-sdk/e2b/envd/versions.py @@ -6,3 +6,4 @@ ENVD_DEFAULT_USER = Version("0.4.0") ENVD_ENVD_CLOSE = Version("0.5.2") ENVD_OCTET_STREAM_UPLOAD = Version("0.5.7") +ENVD_FILE_METADATA = Version("0.5.26") diff --git a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py index 1cee7327a5..b7d5d7d5c2 100644 --- a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py @@ -1,9 +1,9 @@ import gzip -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import Enum from io import IOBase, TextIOBase -from typing import IO, Optional, Union, TypedDict +from typing import IO, Dict, Optional, Union, TypedDict from e2b.envd.filesystem import filesystem_pb2 from e2b.exceptions import InvalidArgumentException @@ -49,6 +49,12 @@ class WriteInfo: """ Path to the filesystem object. """ + metadata: Optional[Dict[str, str]] = field(default=None, kw_only=True) + """ + User-defined metadata persisted on the file as extended attributes. + Only populated when metadata was supplied on upload and the sandbox's + envd supports it. `None` when no metadata is set. + """ @dataclass @@ -114,3 +120,22 @@ def to_upload_body( raise InvalidArgumentException(f"Unsupported data type: {type(data)}") return gzip.compress(raw) if use_gzip else raw + + +METADATA_HEADER_PREFIX = "X-Metadata-" + + +def metadata_to_headers( + metadata: Optional[Dict[str, str]], +) -> Dict[str, str]: + """Translate user metadata into the `X-Metadata-*` upload headers envd reads.""" + if not metadata: + return {} + return {f"{METADATA_HEADER_PREFIX}{key}": value for key, value in metadata.items()} + + +def map_metadata(metadata) -> Optional[Dict[str, str]]: + """Normalize a proto/HTTP metadata map: drop empties and return a plain dict or None.""" + if not metadata: + return None + return dict(metadata) diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 309f4f6169..f8f2fd7299 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -1,6 +1,6 @@ import asyncio from io import IOBase, TextIOBase -from typing import IO, AsyncIterator, List, Literal, Optional, Union, overload +from typing import IO, AsyncIterator, Dict, List, Literal, Optional, Union, overload import httpcore @@ -20,6 +20,7 @@ from e2b.envd.rpc import authentication_header, handle_rpc_exception from e2b.envd.versions import ( ENVD_DEFAULT_USER, + ENVD_FILE_METADATA, ENVD_OCTET_STREAM_UPLOAD, ENVD_VERSION_RECURSIVE_WATCH, ) @@ -34,6 +35,8 @@ WriteEntry, WriteInfo, map_file_type, + map_metadata, + metadata_to_headers, to_upload_body, ) from e2b.sandbox.filesystem.watch_handle import FilesystemEvent @@ -58,6 +61,15 @@ async def _ahandle_filesystem_envd_api_exception(r): return await ahandle_envd_api_exception(r, _FILESYSTEM_HTTP_ERROR_MAP) +def _write_info_from_dict(payload: Dict) -> WriteInfo: + return WriteInfo( + name=payload["name"], + type=payload.get("type"), + path=payload["path"], + metadata=map_metadata(payload.get("metadata")), + ) + + class Filesystem: """ Module for interacting with the filesystem in the sandbox. @@ -198,6 +210,7 @@ async def write( request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: bool = False, + metadata: Optional[Dict[str, str]] = None, ) -> WriteInfo: """ Write content to a file on the path. @@ -211,6 +224,7 @@ async def write( :param request_timeout: Timeout for the request in **seconds** :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys must be US-ASCII. Requires envd 0.5.26 or later. :return: Information about the written file """ @@ -220,6 +234,7 @@ async def write( request_timeout, gzip, use_octet_stream, + metadata, ) if len(result) != 1: @@ -234,6 +249,7 @@ async def write_files( request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: bool = False, + metadata: Optional[Dict[str, str]] = None, ) -> List[WriteInfo]: """ Writes multiple files. @@ -248,6 +264,7 @@ async def write_files( :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Requires envd 0.5.26 or later. :return: Information about the written files """ username = user @@ -257,9 +274,17 @@ async def write_files( if len(files) == 0: return [] + if metadata and self._envd_version < ENVD_FILE_METADATA: + raise TemplateException( + "File metadata requires envd 0.5.26 or later. " + "You can update the template by running `e2b template build` in the directory with the template." + ) + supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream + extra_headers = metadata_to_headers(metadata) + results: List[WriteInfo] = [] if use_octet_stream: @@ -273,7 +298,7 @@ async def _upload_file(file): if username: params["username"] = username - headers = {"Content-Type": "application/octet-stream"} + headers = {"Content-Type": "application/octet-stream", **extra_headers} if gzip: headers["Content-Encoding"] = "gzip" @@ -298,7 +323,7 @@ async def _upload_file(file): "Expected to receive information about written file" ) - return [WriteInfo(**f) for f in write_result] + return [_write_info_from_dict(f) for f in write_result] upload_results = await asyncio.gather( *[_upload_file(file) for file in files] @@ -333,6 +358,7 @@ async def _upload_file(file): ENVD_API_FILES_ROUTE, files=httpx_files, params=params, + headers=extra_headers, timeout=self._connection_config.get_request_timeout(request_timeout), ) @@ -347,7 +373,7 @@ async def _upload_file(file): "Expected to receive information about written file" ) - results.extend([WriteInfo(**f) for f in write_result]) + results.extend([_write_info_from_dict(f) for f in write_result]) return results @@ -402,6 +428,7 @@ async def list( if entry.HasField("symlink_target") else None ), + metadata=map_metadata(entry.metadata), ) ) @@ -480,6 +507,7 @@ async def get_info( if r.entry.HasField("symlink_target") else None ), + metadata=map_metadata(r.entry.metadata), ) except Exception as e: raise _handle_filesystem_rpc_exception(e) @@ -553,6 +581,7 @@ async def rename( if r.entry.HasField("symlink_target") else None ), + metadata=map_metadata(r.entry.metadata), ) except Exception as e: raise _handle_filesystem_rpc_exception(e) diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index b145200e41..5d3cbad6e6 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -1,5 +1,5 @@ from io import IOBase, TextIOBase -from typing import IO, Iterator, List, Literal, Optional, Union, overload +from typing import IO, Dict, Iterator, List, Literal, Optional, Union, overload import httpcore import httpx @@ -20,6 +20,7 @@ from e2b.envd.rpc import authentication_header, handle_rpc_exception from e2b.envd.versions import ( ENVD_DEFAULT_USER, + ENVD_FILE_METADATA, ENVD_OCTET_STREAM_UPLOAD, ENVD_VERSION_RECURSIVE_WATCH, ) @@ -34,6 +35,8 @@ WriteEntry, WriteInfo, map_file_type, + map_metadata, + metadata_to_headers, to_upload_body, ) from e2b.sandbox_sync.filesystem.watch_handle import WatchHandle @@ -56,6 +59,15 @@ def _handle_filesystem_envd_api_exception(r): return handle_envd_api_exception(r, _FILESYSTEM_HTTP_ERROR_MAP) +def _write_info_from_dict(payload: Dict) -> WriteInfo: + return WriteInfo( + name=payload["name"], + type=payload.get("type"), + path=payload["path"], + metadata=map_metadata(payload.get("metadata")), + ) + + class Filesystem: """ Module for interacting with the filesystem in the sandbox. @@ -196,6 +208,7 @@ def write( request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: bool = False, + metadata: Optional[Dict[str, str]] = None, ) -> WriteInfo: """ Write content to a file on the path. @@ -209,6 +222,7 @@ def write( :param request_timeout: Timeout for the request in **seconds** :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys must be US-ASCII. Requires envd 0.5.26 or later. :return: Information about the written file """ @@ -218,6 +232,7 @@ def write( request_timeout=request_timeout, gzip=gzip, use_octet_stream=use_octet_stream, + metadata=metadata, ) if len(result) != 1: @@ -232,6 +247,7 @@ def write_files( request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: bool = False, + metadata: Optional[Dict[str, str]] = None, ) -> List[WriteInfo]: """ Writes a list of files to the filesystem. @@ -244,6 +260,7 @@ def write_files( :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Requires envd 0.5.26 or later. :return: Information about the written files """ username = user @@ -253,9 +270,17 @@ def write_files( if len(files) == 0: return [] + if metadata and self._envd_version < ENVD_FILE_METADATA: + raise TemplateException( + "File metadata requires envd 0.5.26 or later. " + "You can update the template by running `e2b template build` in the directory with the template." + ) + supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream + extra_headers = metadata_to_headers(metadata) + results: List[WriteInfo] = [] if use_octet_stream: @@ -268,7 +293,7 @@ def write_files( if username: params["username"] = username - headers = {"Content-Type": "application/octet-stream"} + headers = {"Content-Type": "application/octet-stream", **extra_headers} if gzip: headers["Content-Encoding"] = "gzip" @@ -293,7 +318,7 @@ def write_files( "Expected to receive information about written file" ) - results.extend([WriteInfo(**f) for f in write_result]) + results.extend([_write_info_from_dict(f) for f in write_result]) else: params = {} if username: @@ -322,6 +347,7 @@ def write_files( ENVD_API_FILES_ROUTE, files=httpx_files, params=params, + headers=extra_headers, timeout=self._connection_config.get_request_timeout(request_timeout), ) @@ -336,7 +362,7 @@ def write_files( "Expected to receive information about written file" ) - results.extend([WriteInfo(**f) for f in write_result]) + results.extend([_write_info_from_dict(f) for f in write_result]) return results @@ -391,6 +417,7 @@ def list( if entry.HasField("symlink_target") else None ), + metadata=map_metadata(entry.metadata), ) ) @@ -469,6 +496,7 @@ def get_info( if r.entry.HasField("symlink_target") else None ), + metadata=map_metadata(r.entry.metadata), ) except Exception as e: raise _handle_filesystem_rpc_exception(e) @@ -542,6 +570,7 @@ def rename( if r.entry.HasField("symlink_target") else None ), + metadata=map_metadata(r.entry.metadata), ) except Exception as e: raise _handle_filesystem_rpc_exception(e) diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py new file mode 100644 index 0000000000..14274eadc5 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py @@ -0,0 +1,120 @@ +from e2b import AsyncSandbox +from e2b.sandbox.filesystem.filesystem import WriteEntry + + +async def test_write_file_with_metadata(async_sandbox: AsyncSandbox, debug): + filename = "test_metadata.txt" + content = "This is a test file with metadata." + metadata = {"author": "mish", "purpose": "upload"} + + info = await async_sandbox.files.write(filename, content, metadata=metadata) + assert info.metadata == metadata + + # Metadata is persisted and surfaced on subsequent reads. + stat = await async_sandbox.files.get_info(filename) + assert stat.metadata == metadata + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_file_with_metadata_octet_stream( + async_sandbox: AsyncSandbox, debug +): + filename = "test_metadata_octet.txt" + content = "This is a test file with metadata." + metadata = {"author": "mish", "purpose": "upload"} + + info = await async_sandbox.files.write( + filename, content, metadata=metadata, use_octet_stream=True + ) + assert info.metadata == metadata + + stat = await async_sandbox.files.get_info(filename) + assert stat.metadata == metadata + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_file_without_metadata(async_sandbox: AsyncSandbox, debug): + filename = "test_no_metadata.txt" + + info = await async_sandbox.files.write(filename, "no metadata here") + assert info.metadata is None + + stat = await async_sandbox.files.get_info(filename) + assert stat.metadata is None + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_files_applies_metadata_to_every_file( + async_sandbox: AsyncSandbox, debug +): + metadata = {"source": "test-suite"} + files = [ + WriteEntry(path="metadata_multi_1.txt", data="File 1"), + WriteEntry(path="metadata_multi_2.txt", data="File 2"), + ] + + infos = await async_sandbox.files.write_files(files, metadata=metadata) + assert len(infos) == len(files) + + for info in infos: + assert info.metadata == metadata + stat = await async_sandbox.files.get_info(info.path) + assert stat.metadata == metadata + + if debug: + for file in files: + await async_sandbox.files.remove(file["path"]) + + +async def test_metadata_surfaced_when_listing(async_sandbox: AsyncSandbox, debug): + dirname = "metadata_list_dir" + filename = "listed.txt" + metadata = {"tag": "listed"} + + await async_sandbox.files.make_dir(dirname) + await async_sandbox.files.write( + f"{dirname}/{filename}", "content", metadata=metadata + ) + + entries = await async_sandbox.files.list(dirname) + entry = next((e for e in entries if e.name == filename), None) + assert entry is not None + assert entry.metadata == metadata + + if debug: + await async_sandbox.files.remove(dirname) + + +async def test_metadata_surfaced_after_rename(async_sandbox: AsyncSandbox, debug): + old_path = "metadata_rename_old.txt" + new_path = "metadata_rename_new.txt" + metadata = {"stage": "renamed"} + + await async_sandbox.files.write(old_path, "content", metadata=metadata) + info = await async_sandbox.files.rename(old_path, new_path) + assert info.metadata == metadata + + if debug: + await async_sandbox.files.remove(new_path) + + +async def test_overwriting_clears_stale_metadata(async_sandbox: AsyncSandbox, debug): + filename = "metadata_overwrite.txt" + + await async_sandbox.files.write(filename, "first", metadata={"author": "mish"}) + + # Overwriting without metadata removes the previously stored metadata. + info = await async_sandbox.files.write(filename, "second") + assert info.metadata is None + + stat = await async_sandbox.files.get_info(filename) + assert stat.metadata is None + + if debug: + await async_sandbox.files.remove(filename) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py new file mode 100644 index 0000000000..c2fead41bd --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py @@ -0,0 +1,112 @@ +def test_write_file_with_metadata(sandbox, debug): + filename = "test_metadata.txt" + content = "This is a test file with metadata." + metadata = {"author": "mish", "purpose": "upload"} + + info = sandbox.files.write(filename, content, metadata=metadata) + assert info.metadata == metadata + + # Metadata is persisted and surfaced on subsequent reads. + stat = sandbox.files.get_info(filename) + assert stat.metadata == metadata + + if debug: + sandbox.files.remove(filename) + + +def test_write_file_with_metadata_octet_stream(sandbox, debug): + filename = "test_metadata_octet.txt" + content = "This is a test file with metadata." + metadata = {"author": "mish", "purpose": "upload"} + + info = sandbox.files.write( + filename, content, metadata=metadata, use_octet_stream=True + ) + assert info.metadata == metadata + + stat = sandbox.files.get_info(filename) + assert stat.metadata == metadata + + if debug: + sandbox.files.remove(filename) + + +def test_write_file_without_metadata(sandbox, debug): + filename = "test_no_metadata.txt" + + info = sandbox.files.write(filename, "no metadata here") + assert info.metadata is None + + stat = sandbox.files.get_info(filename) + assert stat.metadata is None + + if debug: + sandbox.files.remove(filename) + + +def test_write_files_applies_metadata_to_every_file(sandbox, debug): + from e2b.sandbox.filesystem.filesystem import WriteEntry + + metadata = {"source": "test-suite"} + files = [ + WriteEntry(path="metadata_multi_1.txt", data="File 1"), + WriteEntry(path="metadata_multi_2.txt", data="File 2"), + ] + + infos = sandbox.files.write_files(files, metadata=metadata) + assert len(infos) == len(files) + + for info in infos: + assert info.metadata == metadata + stat = sandbox.files.get_info(info.path) + assert stat.metadata == metadata + + if debug: + for file in files: + sandbox.files.remove(file["path"]) + + +def test_metadata_surfaced_when_listing(sandbox, debug): + dirname = "metadata_list_dir" + filename = "listed.txt" + metadata = {"tag": "listed"} + + sandbox.files.make_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", "content", metadata=metadata) + + entries = sandbox.files.list(dirname) + entry = next((e for e in entries if e.name == filename), None) + assert entry is not None + assert entry.metadata == metadata + + if debug: + sandbox.files.remove(dirname) + + +def test_metadata_surfaced_after_rename(sandbox, debug): + old_path = "metadata_rename_old.txt" + new_path = "metadata_rename_new.txt" + metadata = {"stage": "renamed"} + + sandbox.files.write(old_path, "content", metadata=metadata) + info = sandbox.files.rename(old_path, new_path) + assert info.metadata == metadata + + if debug: + sandbox.files.remove(new_path) + + +def test_overwriting_clears_stale_metadata(sandbox, debug): + filename = "metadata_overwrite.txt" + + sandbox.files.write(filename, "first", metadata={"author": "mish"}) + + # Overwriting without metadata removes the previously stored metadata. + info = sandbox.files.write(filename, "second") + assert info.metadata is None + + stat = sandbox.files.get_info(filename) + assert stat.metadata is None + + if debug: + sandbox.files.remove(filename) diff --git a/spec/envd/envd.yaml b/spec/envd/envd.yaml index 9649c24a11..af4c5a1cc2 100644 --- a/spec/envd/envd.yaml +++ b/spec/envd/envd.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: envd - version: 0.1.1 + version: 0.1.3 description: API for managing files' content and controlling envd tags: @@ -102,6 +102,13 @@ paths: $ref: '#/components/responses/InternalServerError' post: summary: Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. + description: | + Any request header of the form `X-Metadata-: ` is persisted + as a user-defined extended attribute on the uploaded file. Keys are + lowercased and the `X-Metadata-` prefix is stripped; the resulting + metadata is returned on `EntryInfo` lookups (e.g. `Stat`, `ListDir`). + Header values must be US-ASCII. Multiple files in a single multipart + upload receive the same metadata. tags: [files] security: - AccessTokenAuth: [] @@ -252,6 +259,11 @@ components: description: Type of the file enum: - file + metadata: + type: object + description: User-defined metadata stored as extended attributes on the file. + additionalProperties: + type: string EnvVars: type: object description: Environment variables to set diff --git a/spec/envd/filesystem/filesystem.proto b/spec/envd/filesystem/filesystem.proto index ca9aeb12df..6b0e5b639d 100644 --- a/spec/envd/filesystem/filesystem.proto +++ b/spec/envd/filesystem/filesystem.proto @@ -62,6 +62,9 @@ message EntryInfo { google.protobuf.Timestamp modified_time = 9; // If the entry is a symlink, this field contains the target of the symlink. optional string symlink_target = 10; + // User-defined metadata stored as extended attributes (xattrs) on the file. + // Keys live in the `user.` xattr namespace; the prefix is stripped here. + map metadata = 11; } enum FileType { From a26b9a59e75f73ad5ab356f1aa329ff7929f04ea Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:41:42 +0200 Subject: [PATCH 2/8] chore(sdks): sync envd spec/proto and bump metadata version gate to 0.6.2 Re-syncs the envd OpenAPI spec and filesystem proto with the latest infra#2732 (refined upload description and user.e2b. xattr namespace note), regenerates the JS client, and pins the file-metadata version gate to envd 0.6.2 (the version that ships the feature). Co-Authored-By: Claude Opus 4.8 --- .changeset/file-metadata-xattrs.md | 2 +- .../src/envd/filesystem/filesystem_pb.ts | 3 ++- packages/js-sdk/src/envd/schema.gen.ts | 24 +++++++++++++++---- packages/js-sdk/src/envd/versions.ts | 2 +- .../js-sdk/src/sandbox/filesystem/index.ts | 4 ++-- packages/python-sdk/e2b/envd/versions.py | 2 +- .../sandbox_async/filesystem/filesystem.py | 6 ++--- .../e2b/sandbox_sync/filesystem/filesystem.py | 6 ++--- spec/envd/envd.yaml | 24 +++++++++++++++---- spec/envd/filesystem/filesystem.proto | 3 ++- 10 files changed, 53 insertions(+), 23 deletions(-) diff --git a/.changeset/file-metadata-xattrs.md b/.changeset/file-metadata-xattrs.md index 65cf90930f..209abe4c72 100644 --- a/.changeset/file-metadata-xattrs.md +++ b/.changeset/file-metadata-xattrs.md @@ -5,4 +5,4 @@ feat(sdks): expose user-defined file metadata on `sandbox.files` -Adds a `metadata` option to file uploads (`write` / `writeFiles` / `write_files`) and surfaces persisted metadata on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.` xattr namespace and returns them on subsequent filesystem reads. Keys must be US-ASCII; the same metadata map is applied to every file in a multi-file upload. Requires envd 0.5.26 or later. +Adds a `metadata` option to file uploads (`write` / `writeFiles` / `write_files`) and surfaces persisted metadata on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.e2b.` xattr namespace and returns them on subsequent filesystem reads. Keys and values must be printable US-ASCII and keys are lowercased by the sandbox; the same metadata map is applied to every file in a multi-file upload. Requires envd 0.6.2 or later. diff --git a/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts b/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts index 6bd28247f0..ee1b4167c1 100644 --- a/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts +++ b/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts @@ -230,7 +230,8 @@ export type EntryInfo = Message<'filesystem.EntryInfo'> & { /** * User-defined metadata stored as extended attributes (xattrs) on the file. - * Keys live in the `user.` xattr namespace; the prefix is stripped here. + * Keys live under the `user.e2b.` xattr namespace; the prefix is stripped here. + * Plain `user.*` xattrs written by other tooling are not reflected. * * @generated from field: map metadata = 11; */ diff --git a/packages/js-sdk/src/envd/schema.gen.ts b/packages/js-sdk/src/envd/schema.gen.ts index 72d8aec5b3..a6b8725144 100644 --- a/packages/js-sdk/src/envd/schema.gen.ts +++ b/packages/js-sdk/src/envd/schema.gen.ts @@ -77,11 +77,25 @@ export interface paths { /** * Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. * @description Any request header of the form `X-Metadata-: ` is persisted - * as a user-defined extended attribute on the uploaded file. Keys are - * lowercased and the `X-Metadata-` prefix is stripped; the resulting - * metadata is returned on `EntryInfo` lookups (e.g. `Stat`, `ListDir`). - * Header values must be US-ASCII. Multiple files in a single multipart - * upload receive the same metadata. + * as a user-defined extended attribute on the uploaded file. The + * `X-Metadata-` prefix is stripped and the remaining header name is + * lowercased to form the metadata key; the resulting map is returned on + * `EntryInfo` lookups (e.g. `Stat`, `ListDir`). + * + * Each upload replaces the file's metadata with the keys provided in + * that request: keys previously stored but absent from the new request + * are removed, and an upload that sends no `X-Metadata-*` header clears + * all existing metadata. + * + * Both keys and values must be printable US-ASCII (bytes `0x20`-`0x7E`) + * and are rejected with HTTP 400 otherwise. Each key is capped at 246 + * bytes (the Linux VFS xattr-name limit minus the namespace prefix), and + * the combined size of all metadata on a file (keys plus values, with the + * namespace prefix counted per key) is capped at 4096 bytes to stay within + * the filesystem's per-inode xattr budget. Multiple files in a single + * multipart upload receive the same metadata. If the same + * `X-Metadata-` header is sent more than once, only the first + * value is used. * */ post: { diff --git a/packages/js-sdk/src/envd/versions.ts b/packages/js-sdk/src/envd/versions.ts index 8ef8a0e6ab..53e5bf644e 100644 --- a/packages/js-sdk/src/envd/versions.ts +++ b/packages/js-sdk/src/envd/versions.ts @@ -4,4 +4,4 @@ export const ENVD_COMMANDS_STDIN = '0.3.0' export const ENVD_DEFAULT_USER = '0.4.0' export const ENVD_ENVD_CLOSE = '0.5.2' export const ENVD_OCTET_STREAM_UPLOAD = '0.5.7' -export const ENVD_FILE_METADATA = '0.5.26' +export const ENVD_FILE_METADATA = '0.6.2' diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 9a3f5978cc..b914e5fd10 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -212,7 +212,7 @@ export interface FilesystemWriteOpts extends FilesystemRequestOpts { * attributes. Keys and values must be printable US-ASCII and keys are * lowercased by the sandbox, so they may differ in case when read back. * The same metadata is applied to every file in a multi-file upload. - * Requires envd 0.5.26 or later. + * Requires envd 0.6.2 or later. */ metadata?: Record } @@ -473,7 +473,7 @@ export class Filesystem { compareVersions(this.envdApi.version, ENVD_FILE_METADATA) < 0 ) { throw new TemplateError( - 'File metadata requires envd 0.5.26 or later. ' + + 'File metadata requires envd 0.6.2 or later. ' + 'You can update the template by running `e2b template build` in the directory with the template.' ) } diff --git a/packages/python-sdk/e2b/envd/versions.py b/packages/python-sdk/e2b/envd/versions.py index b3ade1b0a8..485f9889d9 100644 --- a/packages/python-sdk/e2b/envd/versions.py +++ b/packages/python-sdk/e2b/envd/versions.py @@ -6,4 +6,4 @@ ENVD_DEFAULT_USER = Version("0.4.0") ENVD_ENVD_CLOSE = Version("0.5.2") ENVD_OCTET_STREAM_UPLOAD = Version("0.5.7") -ENVD_FILE_METADATA = Version("0.5.26") +ENVD_FILE_METADATA = Version("0.6.2") diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index f8f2fd7299..24bd3fa010 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -224,7 +224,7 @@ async def write( :param request_timeout: Timeout for the request in **seconds** :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys must be US-ASCII. Requires envd 0.5.26 or later. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys must be US-ASCII. Requires envd 0.6.2 or later. :return: Information about the written file """ @@ -264,7 +264,7 @@ async def write_files( :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Requires envd 0.5.26 or later. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user @@ -276,7 +276,7 @@ async def write_files( if metadata and self._envd_version < ENVD_FILE_METADATA: raise TemplateException( - "File metadata requires envd 0.5.26 or later. " + "File metadata requires envd 0.6.2 or later. " "You can update the template by running `e2b template build` in the directory with the template." ) diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index 5d3cbad6e6..ed92861f7e 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -222,7 +222,7 @@ def write( :param request_timeout: Timeout for the request in **seconds** :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys must be US-ASCII. Requires envd 0.5.26 or later. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys must be US-ASCII. Requires envd 0.6.2 or later. :return: Information about the written file """ @@ -260,7 +260,7 @@ def write_files( :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Requires envd 0.5.26 or later. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user @@ -272,7 +272,7 @@ def write_files( if metadata and self._envd_version < ENVD_FILE_METADATA: raise TemplateException( - "File metadata requires envd 0.5.26 or later. " + "File metadata requires envd 0.6.2 or later. " "You can update the template by running `e2b template build` in the directory with the template." ) diff --git a/spec/envd/envd.yaml b/spec/envd/envd.yaml index af4c5a1cc2..6b776a7268 100644 --- a/spec/envd/envd.yaml +++ b/spec/envd/envd.yaml @@ -104,11 +104,25 @@ paths: summary: Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. description: | Any request header of the form `X-Metadata-: ` is persisted - as a user-defined extended attribute on the uploaded file. Keys are - lowercased and the `X-Metadata-` prefix is stripped; the resulting - metadata is returned on `EntryInfo` lookups (e.g. `Stat`, `ListDir`). - Header values must be US-ASCII. Multiple files in a single multipart - upload receive the same metadata. + as a user-defined extended attribute on the uploaded file. The + `X-Metadata-` prefix is stripped and the remaining header name is + lowercased to form the metadata key; the resulting map is returned on + `EntryInfo` lookups (e.g. `Stat`, `ListDir`). + + Each upload replaces the file's metadata with the keys provided in + that request: keys previously stored but absent from the new request + are removed, and an upload that sends no `X-Metadata-*` header clears + all existing metadata. + + Both keys and values must be printable US-ASCII (bytes `0x20`-`0x7E`) + and are rejected with HTTP 400 otherwise. Each key is capped at 246 + bytes (the Linux VFS xattr-name limit minus the namespace prefix), and + the combined size of all metadata on a file (keys plus values, with the + namespace prefix counted per key) is capped at 4096 bytes to stay within + the filesystem's per-inode xattr budget. Multiple files in a single + multipart upload receive the same metadata. If the same + `X-Metadata-` header is sent more than once, only the first + value is used. tags: [files] security: - AccessTokenAuth: [] diff --git a/spec/envd/filesystem/filesystem.proto b/spec/envd/filesystem/filesystem.proto index 6b0e5b639d..74692721ef 100644 --- a/spec/envd/filesystem/filesystem.proto +++ b/spec/envd/filesystem/filesystem.proto @@ -63,7 +63,8 @@ message EntryInfo { // If the entry is a symlink, this field contains the target of the symlink. optional string symlink_target = 10; // User-defined metadata stored as extended attributes (xattrs) on the file. - // Keys live in the `user.` xattr namespace; the prefix is stripped here. + // Keys live under the `user.e2b.` xattr namespace; the prefix is stripped here. + // Plain `user.*` xattrs written by other tooling are not reflected. map metadata = 11; } From effe8838b04a8390127470a82a6029b215862d6b Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:56:04 +0200 Subject: [PATCH 3/8] =?UTF-8?q?refactor(sdks):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20per-file=20metadata,=20WriteInfo.from=5Fdict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move file metadata to WriteEntry so each file in a multi-file write can carry its own; keep the metadata option on single-file write/write. - Upload per file when metadata is set (X-Metadata-* headers are request-scoped), preserving the batched multipart path otherwise. - Replace the module-level _write_info_from_dict helper with a WriteInfo.from_dict() classmethod. - Trim the version-gate error message to a single sentence. - Add tests that set metadata via xattrs (user.e2b.*) through sandbox.commands.run and assert it surfaces in getInfo (JS + Python). Co-Authored-By: Claude Opus 4.8 --- .changeset/file-metadata-xattrs.md | 2 +- .../js-sdk/src/sandbox/filesystem/index.ts | 73 +++++++++----- .../tests/sandbox/files/metadata.test.ts | 51 ++++++++-- .../e2b/sandbox/filesystem/filesystem.py | 36 ++++++- .../sandbox_async/filesystem/filesystem.py | 98 ++++++++----------- .../e2b/sandbox_sync/filesystem/filesystem.py | 98 ++++++++----------- .../sandbox_async/files/test_metadata.py | 43 ++++++-- .../sync/sandbox_sync/files/test_metadata.py | 40 ++++++-- 8 files changed, 282 insertions(+), 159 deletions(-) diff --git a/.changeset/file-metadata-xattrs.md b/.changeset/file-metadata-xattrs.md index 209abe4c72..98f41708c9 100644 --- a/.changeset/file-metadata-xattrs.md +++ b/.changeset/file-metadata-xattrs.md @@ -5,4 +5,4 @@ feat(sdks): expose user-defined file metadata on `sandbox.files` -Adds a `metadata` option to file uploads (`write` / `writeFiles` / `write_files`) and surfaces persisted metadata on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.e2b.` xattr namespace and returns them on subsequent filesystem reads. Keys and values must be printable US-ASCII and keys are lowercased by the sandbox; the same metadata map is applied to every file in a multi-file upload. Requires envd 0.6.2 or later. +Adds user-defined file metadata to `sandbox.files`: pass `metadata` when writing a single file, or set it per file via `WriteEntry.metadata` when writing multiple files. Persisted metadata is surfaced on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.e2b.` xattr namespace and returns them on subsequent filesystem reads. Keys and values must be printable US-ASCII and keys are lowercased by the sandbox. Requires envd 0.6.2 or later. diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index b914e5fd10..4c5f3982fa 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -140,6 +140,13 @@ export enum FileType { export type WriteEntry = { path: string data: string | ArrayBuffer | Blob | ReadableStream + /** + * User-defined metadata to persist on the file as extended attributes. + * Keys and values must be printable US-ASCII and keys are lowercased by the + * sandbox, so they may differ in case when read back. Requires envd 0.6.2 or + * later. + */ + metadata?: Record } function mapFileType(fileType: FsFileType) { @@ -208,11 +215,12 @@ export interface FilesystemWriteOpts extends FilesystemRequestOpts { */ useOctetStream?: boolean /** - * User-defined metadata to persist on the uploaded file(s) as extended + * User-defined metadata to persist on the uploaded file as extended * attributes. Keys and values must be printable US-ASCII and keys are * lowercased by the sandbox, so they may differ in case when read back. - * The same metadata is applied to every file in a multi-file upload. - * Requires envd 0.6.2 or later. + * Applies when writing a single file; when writing multiple files set + * metadata per file via {@link WriteEntry.metadata}. Requires envd 0.6.2 or + * later. */ metadata?: Record } @@ -442,6 +450,7 @@ export class Filesystem { | ArrayBuffer | Blob | ReadableStream, + metadata: (opts as FilesystemWriteOpts)?.metadata, }, ], } @@ -466,45 +475,64 @@ export class Filesystem { const useOctetStream = (writeOpts?.useOctetStream ?? false) && supportsOctetStream - const metadata = writeOpts?.metadata + const hasMetadata = writeFiles.some( + (file) => file.metadata && Object.keys(file.metadata as object).length > 0 + ) if ( - metadata && - Object.keys(metadata).length > 0 && + hasMetadata && compareVersions(this.envdApi.version, ENVD_FILE_METADATA) < 0 ) { - throw new TemplateError( - 'File metadata requires envd 0.6.2 or later. ' + - 'You can update the template by running `e2b template build` in the directory with the template.' - ) + throw new TemplateError('File metadata requires envd 0.6.2 or later.') } - const extraHeaders = metadataHeaders(metadata) const results: WriteInfo[] = [] const useGzip = writeOpts?.gzip === true - if (useOctetStream) { - const headers: Record = { - 'Content-Type': 'application/octet-stream', - ...extraHeaders, - } - if (useGzip) { - headers['Content-Encoding'] = 'gzip' - } + // Metadata is sent as request-scoped `X-Metadata-*` headers, so a file with + // its own metadata must be uploaded in its own request. octet-stream + // already uploads one file per request; multipart batches files into a + // single request, so fall back to per-file uploads only when metadata is set. + const perFileUpload = useOctetStream || hasMetadata + if (perFileUpload) { const uploadResults = await Promise.all( writeFiles.map(async (file) => { const filePath = path ?? (file as WriteEntry).path - const body = await toUploadBody(file.data, useGzip) + + const headers: Record = { + ...metadataHeaders(file.metadata), + } + if (useOctetStream) { + headers['Content-Type'] = 'application/octet-stream' + } + if (useGzip) { + headers['Content-Encoding'] = 'gzip' + } + + // octet-stream carries the path in the query (the raw body has no + // filename); multipart carries it via the form-data filename. + let bodySerializer: () => BodyInit + let queryPath: string | undefined + if (useOctetStream) { + const body = await toUploadBody(file.data, useGzip) + bodySerializer = () => body + queryPath = filePath + } else { + const formData = new FormData() + formData.append('file', await toBlob(file.data), filePath) + bodySerializer = () => formData + queryPath = path + } const res = await this.envdApi.api.POST('/files', { params: { query: { - path: filePath, + path: queryPath, username: user, }, }, - bodySerializer: () => body, + bodySerializer, headers, signal: this.connectionConfig.getSignal( writeOpts?.requestTimeoutMs, @@ -554,7 +582,6 @@ export class Filesystem { }, }, bodySerializer: () => formData, - headers: extraHeaders, signal: this.connectionConfig.getSignal( writeOpts?.requestTimeoutMs, writeOpts?.signal diff --git a/packages/js-sdk/tests/sandbox/files/metadata.test.ts b/packages/js-sdk/tests/sandbox/files/metadata.test.ts index 3ebd91698e..9074d7e045 100644 --- a/packages/js-sdk/tests/sandbox/files/metadata.test.ts +++ b/packages/js-sdk/tests/sandbox/files/metadata.test.ts @@ -60,20 +60,30 @@ sandboxTest('write file without metadata', async ({ sandbox }) => { sandboxTest( 'writeFiles applies metadata to every file', async ({ sandbox }) => { - const metadata = { source: 'test-suite' } + // Metadata is set per WriteEntry, so each file can carry its own. const files: WriteEntry[] = [ - { path: 'metadata_multi_1.txt', data: 'File 1' }, - { path: 'metadata_multi_2.txt', data: 'File 2' }, + { + path: 'metadata_multi_1.txt', + data: 'File 1', + metadata: { source: 'test-suite', index: '1' }, + }, + { + path: 'metadata_multi_2.txt', + data: 'File 2', + metadata: { source: 'test-suite', index: '2' }, + }, ] - const infos = await sandbox.files.writeFiles(files, { metadata }) + const infos = await sandbox.files.writeFiles(files) assert.equal(infos.length, files.length) - for (const info of infos) { - assert.deepEqual(info.metadata, metadata) + for (let i = 0; i < files.length; i++) { + const info = infos.find((e) => e.path.endsWith(files[i].path)) + assert.isDefined(info) + assert.deepEqual(info?.metadata, files[i].metadata) - const stat = await sandbox.files.getInfo(info.path) - assert.deepEqual(stat.metadata, metadata) + const stat = await sandbox.files.getInfo(info!.path) + assert.deepEqual(stat.metadata, files[i].metadata) } if (isDebug) { @@ -134,3 +144,28 @@ sandboxTest('overwriting a file clears stale metadata', async ({ sandbox }) => { await sandbox.files.remove(filename) } }) + +sandboxTest( + 'metadata set via xattrs is surfaced in getInfo', + async ({ sandbox }) => { + const filename = 'metadata_xattr.txt' + await sandbox.files.write(filename, 'content') + + const { stdout: filePath } = await sandbox.commands.run( + `realpath ${filename}` + ) + + // Set an xattr directly in the `user.e2b.` namespace; it should surface as + // metadata (with the namespace prefix stripped) when reading the file info. + await sandbox.commands.run( + `setfattr -n user.e2b.author -v mish ${filePath.trim()}` + ) + + const info = await sandbox.files.getInfo(filename) + assert.deepEqual(info.metadata, { author: 'mish' }) + + if (isDebug) { + await sandbox.files.remove(filename) + } + } +) diff --git a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py index b7d5d7d5c2..bbf7affff3 100644 --- a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py @@ -56,6 +56,16 @@ class WriteInfo: envd supports it. `None` when no metadata is set. """ + @classmethod + def from_dict(cls, payload: Dict) -> "WriteInfo": + """Build a `WriteInfo` from a `/files` upload response entry.""" + return cls( + name=payload["name"], + type=payload.get("type"), + path=payload["path"], + metadata=map_metadata(payload.get("metadata")), + ) + @dataclass class EntryInfo(WriteInfo): @@ -94,13 +104,33 @@ class EntryInfo(WriteInfo): """ -class WriteEntry(TypedDict): +class _WriteEntryRequired(TypedDict): + path: str + data: Union[str, bytes, IO] + + +class WriteEntry(_WriteEntryRequired, total=False): """ Contains path and data of the file to be written to the filesystem. + + Optionally carries user-defined `metadata` to persist on the file as + extended attributes. Keys and values must be printable US-ASCII and keys + are lowercased by the sandbox. Requires envd 0.6.2 or later. """ - path: str - data: Union[str, bytes, IO] + metadata: Optional[Dict[str, str]] + + +def _to_httpx_file(file_path: str, file_data: Union[str, bytes, IO]): + """Build an httpx multipart `("file", (name, data))` tuple for the upload.""" + if isinstance(file_data, (str, bytes)): + return ("file", (file_path, file_data)) + elif isinstance(file_data, TextIOBase): + return ("file", (file_path, file_data.read())) + elif isinstance(file_data, IOBase): + return ("file", (file_path, file_data)) + else: + raise InvalidArgumentException(f"Unsupported data type for file {file_path}") def to_upload_body( diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 24bd3fa010..17c29e0b44 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -1,5 +1,4 @@ import asyncio -from io import IOBase, TextIOBase from typing import IO, AsyncIterator, Dict, List, Literal, Optional, Union, overload @@ -34,6 +33,7 @@ EntryInfo, WriteEntry, WriteInfo, + _to_httpx_file, map_file_type, map_metadata, metadata_to_headers, @@ -61,15 +61,6 @@ async def _ahandle_filesystem_envd_api_exception(r): return await ahandle_envd_api_exception(r, _FILESYSTEM_HTTP_ERROR_MAP) -def _write_info_from_dict(payload: Dict) -> WriteInfo: - return WriteInfo( - name=payload["name"], - type=payload.get("type"), - path=payload["path"], - metadata=map_metadata(payload.get("metadata")), - ) - - class Filesystem: """ Module for interacting with the filesystem in the sandbox. @@ -229,12 +220,11 @@ async def write( :return: Information about the written file """ result = await self.write_files( - [WriteEntry(path=path, data=data)], + [WriteEntry(path=path, data=data, metadata=metadata)], user, request_timeout, gzip, use_octet_stream, - metadata, ) if len(result) != 1: @@ -249,7 +239,6 @@ async def write_files( request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: bool = False, - metadata: Optional[Dict[str, str]] = None, ) -> List[WriteInfo]: """ Writes multiple files. @@ -259,12 +248,11 @@ async def write_files( When writing to a file that already exists, the file will get overwritten. When writing to a file at path that doesn't exist, the necessary directories will be created. - :param files: list of files to write as `WriteEntry` objects, each containing `path` and `data` + :param files: list of files to write as `WriteEntry` objects, each containing `path`, `data` and optional `metadata` :param user: Run the operation as this user :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user @@ -274,43 +262,56 @@ async def write_files( if len(files) == 0: return [] - if metadata and self._envd_version < ENVD_FILE_METADATA: - raise TemplateException( - "File metadata requires envd 0.6.2 or later. " - "You can update the template by running `e2b template build` in the directory with the template." - ) + has_metadata = any(file.get("metadata") for file in files) + if has_metadata and self._envd_version < ENVD_FILE_METADATA: + raise TemplateException("File metadata requires envd 0.6.2 or later.") supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream - extra_headers = metadata_to_headers(metadata) - results: List[WriteInfo] = [] - if use_octet_stream: + # Metadata is sent as request-scoped X-Metadata-* headers, so files that + # carry metadata must be uploaded one request at a time. octet-stream + # already uploads per file; multipart batches files into one request, so + # fall back to per-file uploads only when metadata is involved. + per_file_upload = use_octet_stream or has_metadata + + if per_file_upload: async def _upload_file(file): file_path, file_data = file["path"], file["data"] + headers = metadata_to_headers(file.get("metadata")) - content = to_upload_body(file_data, gzip) - - params = {"path": file_path} + params = {} if username: params["username"] = username - headers = {"Content-Type": "application/octet-stream", **extra_headers} - if gzip: - headers["Content-Encoding"] = "gzip" - - r = await self._envd_api.post( - ENVD_API_FILES_ROUTE, - content=content, - headers=headers, - params=params, - timeout=self._connection_config.get_request_timeout( - request_timeout - ), - ) + if use_octet_stream: + params["path"] = file_path + headers["Content-Type"] = "application/octet-stream" + if gzip: + headers["Content-Encoding"] = "gzip" + + r = await self._envd_api.post( + ENVD_API_FILES_ROUTE, + content=to_upload_body(file_data, gzip), + headers=headers, + params=params, + timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + else: + r = await self._envd_api.post( + ENVD_API_FILES_ROUTE, + files=[_to_httpx_file(file_path, file_data)], + headers=headers, + params=params, + timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) err = await _ahandle_filesystem_envd_api_exception(r) if err: @@ -323,7 +324,7 @@ async def _upload_file(file): "Expected to receive information about written file" ) - return [_write_info_from_dict(f) for f in write_result] + return [WriteInfo.from_dict(f) for f in write_result] upload_results = await asyncio.gather( *[_upload_file(file) for file in files] @@ -337,19 +338,7 @@ async def _upload_file(file): if len(files) == 1: params["path"] = files[0]["path"] - httpx_files = [] - for file in files: - file_path, file_data = file["path"], file["data"] - if isinstance(file_data, (str, bytes)): - httpx_files.append(("file", (file_path, file_data))) - elif isinstance(file_data, TextIOBase): - httpx_files.append(("file", (file_path, file_data.read()))) - elif isinstance(file_data, IOBase): - httpx_files.append(("file", (file_path, file_data))) - else: - raise InvalidArgumentException( - f"Unsupported data type for file {file_path}" - ) + httpx_files = [_to_httpx_file(file["path"], file["data"]) for file in files] if len(httpx_files) == 0: return [] @@ -358,7 +347,6 @@ async def _upload_file(file): ENVD_API_FILES_ROUTE, files=httpx_files, params=params, - headers=extra_headers, timeout=self._connection_config.get_request_timeout(request_timeout), ) @@ -373,7 +361,7 @@ async def _upload_file(file): "Expected to receive information about written file" ) - results.extend([_write_info_from_dict(f) for f in write_result]) + results.extend([WriteInfo.from_dict(f) for f in write_result]) return results diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index ed92861f7e..7a314a914e 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -1,4 +1,3 @@ -from io import IOBase, TextIOBase from typing import IO, Dict, Iterator, List, Literal, Optional, Union, overload import httpcore @@ -34,6 +33,7 @@ EntryInfo, WriteEntry, WriteInfo, + _to_httpx_file, map_file_type, map_metadata, metadata_to_headers, @@ -59,15 +59,6 @@ def _handle_filesystem_envd_api_exception(r): return handle_envd_api_exception(r, _FILESYSTEM_HTTP_ERROR_MAP) -def _write_info_from_dict(payload: Dict) -> WriteInfo: - return WriteInfo( - name=payload["name"], - type=payload.get("type"), - path=payload["path"], - metadata=map_metadata(payload.get("metadata")), - ) - - class Filesystem: """ Module for interacting with the filesystem in the sandbox. @@ -227,12 +218,11 @@ def write( :return: Information about the written file """ result = self.write_files( - [WriteEntry(path=path, data=data)], + [WriteEntry(path=path, data=data, metadata=metadata)], user=user, request_timeout=request_timeout, gzip=gzip, use_octet_stream=use_octet_stream, - metadata=metadata, ) if len(result) != 1: @@ -247,7 +237,6 @@ def write_files( request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: bool = False, - metadata: Optional[Dict[str, str]] = None, ) -> List[WriteInfo]: """ Writes a list of files to the filesystem. @@ -255,12 +244,11 @@ def write_files( When writing to a file that already exists, the file will get overwritten. When writing to a file at path that doesn't exist, the necessary directories will be created. - :param files: list of files to write as `WriteEntry` objects, each containing `path` and `data` + :param files: list of files to write as `WriteEntry` objects, each containing `path`, `data` and optional `metadata` :param user: Run the operation as this user :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user @@ -270,42 +258,55 @@ def write_files( if len(files) == 0: return [] - if metadata and self._envd_version < ENVD_FILE_METADATA: - raise TemplateException( - "File metadata requires envd 0.6.2 or later. " - "You can update the template by running `e2b template build` in the directory with the template." - ) + has_metadata = any(file.get("metadata") for file in files) + if has_metadata and self._envd_version < ENVD_FILE_METADATA: + raise TemplateException("File metadata requires envd 0.6.2 or later.") supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream - extra_headers = metadata_to_headers(metadata) - results: List[WriteInfo] = [] - if use_octet_stream: + # Metadata is sent as request-scoped X-Metadata-* headers, so files that + # carry metadata must be uploaded one request at a time. octet-stream + # already uploads per file; multipart batches files into one request, so + # fall back to per-file uploads only when metadata is involved. + per_file_upload = use_octet_stream or has_metadata + + if per_file_upload: for file in files: file_path, file_data = file["path"], file["data"] + headers = metadata_to_headers(file.get("metadata")) - content = to_upload_body(file_data, gzip) - - params = {"path": file_path} + params = {} if username: params["username"] = username - headers = {"Content-Type": "application/octet-stream", **extra_headers} - if gzip: - headers["Content-Encoding"] = "gzip" - - r = self._envd_api.post( - ENVD_API_FILES_ROUTE, - content=content, - headers=headers, - params=params, - timeout=self._connection_config.get_request_timeout( - request_timeout - ), - ) + if use_octet_stream: + params["path"] = file_path + headers["Content-Type"] = "application/octet-stream" + if gzip: + headers["Content-Encoding"] = "gzip" + + r = self._envd_api.post( + ENVD_API_FILES_ROUTE, + content=to_upload_body(file_data, gzip), + headers=headers, + params=params, + timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + else: + r = self._envd_api.post( + ENVD_API_FILES_ROUTE, + files=[_to_httpx_file(file_path, file_data)], + headers=headers, + params=params, + timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) err = _handle_filesystem_envd_api_exception(r) if err: @@ -318,7 +319,7 @@ def write_files( "Expected to receive information about written file" ) - results.extend([_write_info_from_dict(f) for f in write_result]) + results.extend([WriteInfo.from_dict(f) for f in write_result]) else: params = {} if username: @@ -326,19 +327,7 @@ def write_files( if len(files) == 1: params["path"] = files[0]["path"] - httpx_files = [] - for file in files: - file_path, file_data = file["path"], file["data"] - if isinstance(file_data, (str, bytes)): - httpx_files.append(("file", (file_path, file_data))) - elif isinstance(file_data, TextIOBase): - httpx_files.append(("file", (file_path, file_data.read()))) - elif isinstance(file_data, IOBase): - httpx_files.append(("file", (file_path, file_data))) - else: - raise InvalidArgumentException( - f"Unsupported data type for file {file_path}" - ) + httpx_files = [_to_httpx_file(file["path"], file["data"]) for file in files] if len(httpx_files) == 0: return [] @@ -347,7 +336,6 @@ def write_files( ENVD_API_FILES_ROUTE, files=httpx_files, params=params, - headers=extra_headers, timeout=self._connection_config.get_request_timeout(request_timeout), ) @@ -362,7 +350,7 @@ def write_files( "Expected to receive information about written file" ) - results.extend([_write_info_from_dict(f) for f in write_result]) + results.extend([WriteInfo.from_dict(f) for f in write_result]) return results diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py index 14274eadc5..aa71d1d4ea 100644 --- a/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py @@ -53,19 +53,28 @@ async def test_write_file_without_metadata(async_sandbox: AsyncSandbox, debug): async def test_write_files_applies_metadata_to_every_file( async_sandbox: AsyncSandbox, debug ): - metadata = {"source": "test-suite"} + # Metadata is set per WriteEntry, so each file can carry its own. files = [ - WriteEntry(path="metadata_multi_1.txt", data="File 1"), - WriteEntry(path="metadata_multi_2.txt", data="File 2"), + WriteEntry( + path="metadata_multi_1.txt", + data="File 1", + metadata={"source": "test-suite", "index": "1"}, + ), + WriteEntry( + path="metadata_multi_2.txt", + data="File 2", + metadata={"source": "test-suite", "index": "2"}, + ), ] - infos = await async_sandbox.files.write_files(files, metadata=metadata) + infos = await async_sandbox.files.write_files(files) assert len(infos) == len(files) - for info in infos: - assert info.metadata == metadata + for file in files: + info = next(i for i in infos if i.path.endswith(file["path"])) + assert info.metadata == file["metadata"] stat = await async_sandbox.files.get_info(info.path) - assert stat.metadata == metadata + assert stat.metadata == file["metadata"] if debug: for file in files: @@ -118,3 +127,23 @@ async def test_overwriting_clears_stale_metadata(async_sandbox: AsyncSandbox, de if debug: await async_sandbox.files.remove(filename) + + +async def test_metadata_set_via_xattrs_surfaced_in_get_info( + async_sandbox: AsyncSandbox, debug +): + filename = "metadata_xattr.txt" + await async_sandbox.files.write(filename, "content") + + cmd = await async_sandbox.commands.run(f"realpath {filename}") + file_path = cmd.stdout.strip() + + # Set an xattr directly in the `user.e2b.` namespace; it should surface as + # metadata (with the namespace prefix stripped) when reading the file info. + await async_sandbox.commands.run(f"setfattr -n user.e2b.author -v mish {file_path}") + + info = await async_sandbox.files.get_info(filename) + assert info.metadata == {"author": "mish"} + + if debug: + await async_sandbox.files.remove(filename) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py index c2fead41bd..cb278205a4 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py @@ -47,19 +47,28 @@ def test_write_file_without_metadata(sandbox, debug): def test_write_files_applies_metadata_to_every_file(sandbox, debug): from e2b.sandbox.filesystem.filesystem import WriteEntry - metadata = {"source": "test-suite"} + # Metadata is set per WriteEntry, so each file can carry its own. files = [ - WriteEntry(path="metadata_multi_1.txt", data="File 1"), - WriteEntry(path="metadata_multi_2.txt", data="File 2"), + WriteEntry( + path="metadata_multi_1.txt", + data="File 1", + metadata={"source": "test-suite", "index": "1"}, + ), + WriteEntry( + path="metadata_multi_2.txt", + data="File 2", + metadata={"source": "test-suite", "index": "2"}, + ), ] - infos = sandbox.files.write_files(files, metadata=metadata) + infos = sandbox.files.write_files(files) assert len(infos) == len(files) - for info in infos: - assert info.metadata == metadata + for file in files: + info = next(i for i in infos if i.path.endswith(file["path"])) + assert info.metadata == file["metadata"] stat = sandbox.files.get_info(info.path) - assert stat.metadata == metadata + assert stat.metadata == file["metadata"] if debug: for file in files: @@ -110,3 +119,20 @@ def test_overwriting_clears_stale_metadata(sandbox, debug): if debug: sandbox.files.remove(filename) + + +def test_metadata_set_via_xattrs_surfaced_in_get_info(sandbox, debug): + filename = "metadata_xattr.txt" + sandbox.files.write(filename, "content") + + file_path = sandbox.commands.run(f"realpath {filename}").stdout.strip() + + # Set an xattr directly in the `user.e2b.` namespace; it should surface as + # metadata (with the namespace prefix stripped) when reading the file info. + sandbox.commands.run(f"setfattr -n user.e2b.author -v mish {file_path}") + + info = sandbox.files.get_info(filename) + assert info.metadata == {"author": "mish"} + + if debug: + sandbox.files.remove(filename) From 7049af883bfe5ea7435db896e87f020a00ff86e1 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:07:51 +0200 Subject: [PATCH 4/8] refactor(sdks): keep file metadata as a uniform writeFiles option Revert per-WriteEntry metadata; metadata is again an option on write/writeFiles/write_files and is applied to every file in the upload (sent as request-scoped X-Metadata-* headers). Keeps the earlier review fixes: WriteInfo.from_dict(), the trimmed version-gate message, and the xattr-via-commands tests. Co-Authored-By: Claude Opus 4.8 --- .changeset/file-metadata-xattrs.md | 2 +- .../js-sdk/src/sandbox/filesystem/index.ts | 70 ++++++------------- .../tests/sandbox/files/metadata.test.ts | 27 +++---- .../e2b/sandbox/filesystem/filesystem.py | 14 +--- .../sandbox_async/filesystem/filesystem.py | 64 +++++++---------- .../e2b/sandbox_sync/filesystem/filesystem.py | 64 +++++++---------- .../sandbox_async/files/test_metadata.py | 24 +++---- .../sync/sandbox_sync/files/test_metadata.py | 24 +++---- 8 files changed, 102 insertions(+), 187 deletions(-) diff --git a/.changeset/file-metadata-xattrs.md b/.changeset/file-metadata-xattrs.md index 98f41708c9..209abe4c72 100644 --- a/.changeset/file-metadata-xattrs.md +++ b/.changeset/file-metadata-xattrs.md @@ -5,4 +5,4 @@ feat(sdks): expose user-defined file metadata on `sandbox.files` -Adds user-defined file metadata to `sandbox.files`: pass `metadata` when writing a single file, or set it per file via `WriteEntry.metadata` when writing multiple files. Persisted metadata is surfaced on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.e2b.` xattr namespace and returns them on subsequent filesystem reads. Keys and values must be printable US-ASCII and keys are lowercased by the sandbox. Requires envd 0.6.2 or later. +Adds a `metadata` option to file uploads (`write` / `writeFiles` / `write_files`) and surfaces persisted metadata on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.e2b.` xattr namespace and returns them on subsequent filesystem reads. Keys and values must be printable US-ASCII and keys are lowercased by the sandbox; the same metadata map is applied to every file in a multi-file upload. Requires envd 0.6.2 or later. diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 4c5f3982fa..03fea68855 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -140,13 +140,6 @@ export enum FileType { export type WriteEntry = { path: string data: string | ArrayBuffer | Blob | ReadableStream - /** - * User-defined metadata to persist on the file as extended attributes. - * Keys and values must be printable US-ASCII and keys are lowercased by the - * sandbox, so they may differ in case when read back. Requires envd 0.6.2 or - * later. - */ - metadata?: Record } function mapFileType(fileType: FsFileType) { @@ -215,12 +208,11 @@ export interface FilesystemWriteOpts extends FilesystemRequestOpts { */ useOctetStream?: boolean /** - * User-defined metadata to persist on the uploaded file as extended + * User-defined metadata to persist on the uploaded file(s) as extended * attributes. Keys and values must be printable US-ASCII and keys are * lowercased by the sandbox, so they may differ in case when read back. - * Applies when writing a single file; when writing multiple files set - * metadata per file via {@link WriteEntry.metadata}. Requires envd 0.6.2 or - * later. + * The same metadata is applied to every file in a multi-file upload. + * Requires envd 0.6.2 or later. */ metadata?: Record } @@ -450,7 +442,6 @@ export class Filesystem { | ArrayBuffer | Blob | ReadableStream, - metadata: (opts as FilesystemWriteOpts)?.metadata, }, ], } @@ -475,64 +466,44 @@ export class Filesystem { const useOctetStream = (writeOpts?.useOctetStream ?? false) && supportsOctetStream - const hasMetadata = writeFiles.some( - (file) => file.metadata && Object.keys(file.metadata as object).length > 0 - ) + const metadata = writeOpts?.metadata if ( - hasMetadata && + metadata && + Object.keys(metadata).length > 0 && compareVersions(this.envdApi.version, ENVD_FILE_METADATA) < 0 ) { throw new TemplateError('File metadata requires envd 0.6.2 or later.') } + // Metadata is sent as request-scoped `X-Metadata-*` headers, so the same + // metadata is applied to every file in a multi-file upload. + const extraHeaders = metadataHeaders(metadata) const results: WriteInfo[] = [] const useGzip = writeOpts?.gzip === true - // Metadata is sent as request-scoped `X-Metadata-*` headers, so a file with - // its own metadata must be uploaded in its own request. octet-stream - // already uploads one file per request; multipart batches files into a - // single request, so fall back to per-file uploads only when metadata is set. - const perFileUpload = useOctetStream || hasMetadata + if (useOctetStream) { + const headers: Record = { + 'Content-Type': 'application/octet-stream', + ...extraHeaders, + } + if (useGzip) { + headers['Content-Encoding'] = 'gzip' + } - if (perFileUpload) { const uploadResults = await Promise.all( writeFiles.map(async (file) => { const filePath = path ?? (file as WriteEntry).path - - const headers: Record = { - ...metadataHeaders(file.metadata), - } - if (useOctetStream) { - headers['Content-Type'] = 'application/octet-stream' - } - if (useGzip) { - headers['Content-Encoding'] = 'gzip' - } - - // octet-stream carries the path in the query (the raw body has no - // filename); multipart carries it via the form-data filename. - let bodySerializer: () => BodyInit - let queryPath: string | undefined - if (useOctetStream) { - const body = await toUploadBody(file.data, useGzip) - bodySerializer = () => body - queryPath = filePath - } else { - const formData = new FormData() - formData.append('file', await toBlob(file.data), filePath) - bodySerializer = () => formData - queryPath = path - } + const body = await toUploadBody(file.data, useGzip) const res = await this.envdApi.api.POST('/files', { params: { query: { - path: queryPath, + path: filePath, username: user, }, }, - bodySerializer, + bodySerializer: () => body, headers, signal: this.connectionConfig.getSignal( writeOpts?.requestTimeoutMs, @@ -582,6 +553,7 @@ export class Filesystem { }, }, bodySerializer: () => formData, + headers: extraHeaders, signal: this.connectionConfig.getSignal( writeOpts?.requestTimeoutMs, writeOpts?.signal diff --git a/packages/js-sdk/tests/sandbox/files/metadata.test.ts b/packages/js-sdk/tests/sandbox/files/metadata.test.ts index 9074d7e045..7961573942 100644 --- a/packages/js-sdk/tests/sandbox/files/metadata.test.ts +++ b/packages/js-sdk/tests/sandbox/files/metadata.test.ts @@ -60,30 +60,21 @@ sandboxTest('write file without metadata', async ({ sandbox }) => { sandboxTest( 'writeFiles applies metadata to every file', async ({ sandbox }) => { - // Metadata is set per WriteEntry, so each file can carry its own. + // The same metadata is applied to every file in the upload. + const metadata = { source: 'test-suite' } const files: WriteEntry[] = [ - { - path: 'metadata_multi_1.txt', - data: 'File 1', - metadata: { source: 'test-suite', index: '1' }, - }, - { - path: 'metadata_multi_2.txt', - data: 'File 2', - metadata: { source: 'test-suite', index: '2' }, - }, + { path: 'metadata_multi_1.txt', data: 'File 1' }, + { path: 'metadata_multi_2.txt', data: 'File 2' }, ] - const infos = await sandbox.files.writeFiles(files) + const infos = await sandbox.files.writeFiles(files, { metadata }) assert.equal(infos.length, files.length) - for (let i = 0; i < files.length; i++) { - const info = infos.find((e) => e.path.endsWith(files[i].path)) - assert.isDefined(info) - assert.deepEqual(info?.metadata, files[i].metadata) + for (const info of infos) { + assert.deepEqual(info.metadata, metadata) - const stat = await sandbox.files.getInfo(info!.path) - assert.deepEqual(stat.metadata, files[i].metadata) + const stat = await sandbox.files.getInfo(info.path) + assert.deepEqual(stat.metadata, metadata) } if (isDebug) { diff --git a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py index bbf7affff3..90d48dc7a7 100644 --- a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py @@ -104,21 +104,13 @@ class EntryInfo(WriteInfo): """ -class _WriteEntryRequired(TypedDict): - path: str - data: Union[str, bytes, IO] - - -class WriteEntry(_WriteEntryRequired, total=False): +class WriteEntry(TypedDict): """ Contains path and data of the file to be written to the filesystem. - - Optionally carries user-defined `metadata` to persist on the file as - extended attributes. Keys and values must be printable US-ASCII and keys - are lowercased by the sandbox. Requires envd 0.6.2 or later. """ - metadata: Optional[Dict[str, str]] + path: str + data: Union[str, bytes, IO] def _to_httpx_file(file_path: str, file_data: Union[str, bytes, IO]): diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 17c29e0b44..4105e86d76 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -220,11 +220,12 @@ async def write( :return: Information about the written file """ result = await self.write_files( - [WriteEntry(path=path, data=data, metadata=metadata)], + [WriteEntry(path=path, data=data)], user, request_timeout, gzip, use_octet_stream, + metadata, ) if len(result) != 1: @@ -239,6 +240,7 @@ async def write_files( request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: bool = False, + metadata: Optional[Dict[str, str]] = None, ) -> List[WriteInfo]: """ Writes multiple files. @@ -248,11 +250,12 @@ async def write_files( When writing to a file that already exists, the file will get overwritten. When writing to a file at path that doesn't exist, the necessary directories will be created. - :param files: list of files to write as `WriteEntry` objects, each containing `path`, `data` and optional `metadata` + :param files: list of files to write as `WriteEntry` objects, each containing `path` and `data` :param user: Run the operation as this user :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Keys must be US-ASCII. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user @@ -262,56 +265,40 @@ async def write_files( if len(files) == 0: return [] - has_metadata = any(file.get("metadata") for file in files) - if has_metadata and self._envd_version < ENVD_FILE_METADATA: + if metadata and self._envd_version < ENVD_FILE_METADATA: raise TemplateException("File metadata requires envd 0.6.2 or later.") supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream - results: List[WriteInfo] = [] + # Metadata is sent as request-scoped X-Metadata-* headers, so the same + # metadata is applied to every file in a multi-file upload. + extra_headers = metadata_to_headers(metadata) - # Metadata is sent as request-scoped X-Metadata-* headers, so files that - # carry metadata must be uploaded one request at a time. octet-stream - # already uploads per file; multipart batches files into one request, so - # fall back to per-file uploads only when metadata is involved. - per_file_upload = use_octet_stream or has_metadata + results: List[WriteInfo] = [] - if per_file_upload: + if use_octet_stream: async def _upload_file(file): file_path, file_data = file["path"], file["data"] - headers = metadata_to_headers(file.get("metadata")) - params = {} + params = {"path": file_path} if username: params["username"] = username - if use_octet_stream: - params["path"] = file_path - headers["Content-Type"] = "application/octet-stream" - if gzip: - headers["Content-Encoding"] = "gzip" - - r = await self._envd_api.post( - ENVD_API_FILES_ROUTE, - content=to_upload_body(file_data, gzip), - headers=headers, - params=params, - timeout=self._connection_config.get_request_timeout( - request_timeout - ), - ) - else: - r = await self._envd_api.post( - ENVD_API_FILES_ROUTE, - files=[_to_httpx_file(file_path, file_data)], - headers=headers, - params=params, - timeout=self._connection_config.get_request_timeout( - request_timeout - ), - ) + headers = {"Content-Type": "application/octet-stream", **extra_headers} + if gzip: + headers["Content-Encoding"] = "gzip" + + r = await self._envd_api.post( + ENVD_API_FILES_ROUTE, + content=to_upload_body(file_data, gzip), + headers=headers, + params=params, + timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) err = await _ahandle_filesystem_envd_api_exception(r) if err: @@ -347,6 +334,7 @@ async def _upload_file(file): ENVD_API_FILES_ROUTE, files=httpx_files, params=params, + headers=extra_headers, timeout=self._connection_config.get_request_timeout(request_timeout), ) diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index 7a314a914e..096fbd69e2 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -218,11 +218,12 @@ def write( :return: Information about the written file """ result = self.write_files( - [WriteEntry(path=path, data=data, metadata=metadata)], + [WriteEntry(path=path, data=data)], user=user, request_timeout=request_timeout, gzip=gzip, use_octet_stream=use_octet_stream, + metadata=metadata, ) if len(result) != 1: @@ -237,6 +238,7 @@ def write_files( request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: bool = False, + metadata: Optional[Dict[str, str]] = None, ) -> List[WriteInfo]: """ Writes a list of files to the filesystem. @@ -244,11 +246,12 @@ def write_files( When writing to a file that already exists, the file will get overwritten. When writing to a file at path that doesn't exist, the necessary directories will be created. - :param files: list of files to write as `WriteEntry` objects, each containing `path`, `data` and optional `metadata` + :param files: list of files to write as `WriteEntry` objects, each containing `path` and `data` :param user: Run the operation as this user :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Keys must be US-ASCII. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user @@ -258,55 +261,39 @@ def write_files( if len(files) == 0: return [] - has_metadata = any(file.get("metadata") for file in files) - if has_metadata and self._envd_version < ENVD_FILE_METADATA: + if metadata and self._envd_version < ENVD_FILE_METADATA: raise TemplateException("File metadata requires envd 0.6.2 or later.") supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream - results: List[WriteInfo] = [] + # Metadata is sent as request-scoped X-Metadata-* headers, so the same + # metadata is applied to every file in a multi-file upload. + extra_headers = metadata_to_headers(metadata) - # Metadata is sent as request-scoped X-Metadata-* headers, so files that - # carry metadata must be uploaded one request at a time. octet-stream - # already uploads per file; multipart batches files into one request, so - # fall back to per-file uploads only when metadata is involved. - per_file_upload = use_octet_stream or has_metadata + results: List[WriteInfo] = [] - if per_file_upload: + if use_octet_stream: for file in files: file_path, file_data = file["path"], file["data"] - headers = metadata_to_headers(file.get("metadata")) - params = {} + params = {"path": file_path} if username: params["username"] = username - if use_octet_stream: - params["path"] = file_path - headers["Content-Type"] = "application/octet-stream" - if gzip: - headers["Content-Encoding"] = "gzip" - - r = self._envd_api.post( - ENVD_API_FILES_ROUTE, - content=to_upload_body(file_data, gzip), - headers=headers, - params=params, - timeout=self._connection_config.get_request_timeout( - request_timeout - ), - ) - else: - r = self._envd_api.post( - ENVD_API_FILES_ROUTE, - files=[_to_httpx_file(file_path, file_data)], - headers=headers, - params=params, - timeout=self._connection_config.get_request_timeout( - request_timeout - ), - ) + headers = {"Content-Type": "application/octet-stream", **extra_headers} + if gzip: + headers["Content-Encoding"] = "gzip" + + r = self._envd_api.post( + ENVD_API_FILES_ROUTE, + content=to_upload_body(file_data, gzip), + headers=headers, + params=params, + timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) err = _handle_filesystem_envd_api_exception(r) if err: @@ -336,6 +323,7 @@ def write_files( ENVD_API_FILES_ROUTE, files=httpx_files, params=params, + headers=extra_headers, timeout=self._connection_config.get_request_timeout(request_timeout), ) diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py index aa71d1d4ea..23e4119f51 100644 --- a/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py @@ -53,28 +53,20 @@ async def test_write_file_without_metadata(async_sandbox: AsyncSandbox, debug): async def test_write_files_applies_metadata_to_every_file( async_sandbox: AsyncSandbox, debug ): - # Metadata is set per WriteEntry, so each file can carry its own. + # The same metadata is applied to every file in the upload. + metadata = {"source": "test-suite"} files = [ - WriteEntry( - path="metadata_multi_1.txt", - data="File 1", - metadata={"source": "test-suite", "index": "1"}, - ), - WriteEntry( - path="metadata_multi_2.txt", - data="File 2", - metadata={"source": "test-suite", "index": "2"}, - ), + WriteEntry(path="metadata_multi_1.txt", data="File 1"), + WriteEntry(path="metadata_multi_2.txt", data="File 2"), ] - infos = await async_sandbox.files.write_files(files) + infos = await async_sandbox.files.write_files(files, metadata=metadata) assert len(infos) == len(files) - for file in files: - info = next(i for i in infos if i.path.endswith(file["path"])) - assert info.metadata == file["metadata"] + for info in infos: + assert info.metadata == metadata stat = await async_sandbox.files.get_info(info.path) - assert stat.metadata == file["metadata"] + assert stat.metadata == metadata if debug: for file in files: diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py index cb278205a4..fb66dad63a 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py @@ -47,28 +47,20 @@ def test_write_file_without_metadata(sandbox, debug): def test_write_files_applies_metadata_to_every_file(sandbox, debug): from e2b.sandbox.filesystem.filesystem import WriteEntry - # Metadata is set per WriteEntry, so each file can carry its own. + # The same metadata is applied to every file in the upload. + metadata = {"source": "test-suite"} files = [ - WriteEntry( - path="metadata_multi_1.txt", - data="File 1", - metadata={"source": "test-suite", "index": "1"}, - ), - WriteEntry( - path="metadata_multi_2.txt", - data="File 2", - metadata={"source": "test-suite", "index": "2"}, - ), + WriteEntry(path="metadata_multi_1.txt", data="File 1"), + WriteEntry(path="metadata_multi_2.txt", data="File 2"), ] - infos = sandbox.files.write_files(files) + infos = sandbox.files.write_files(files, metadata=metadata) assert len(infos) == len(files) - for file in files: - info = next(i for i in infos if i.path.endswith(file["path"])) - assert info.metadata == file["metadata"] + for info in infos: + assert info.metadata == metadata stat = sandbox.files.get_info(info.path) - assert stat.metadata == file["metadata"] + assert stat.metadata == metadata if debug: for file in files: From f092251ff5d4ec7a640cbb2c968363c7b6628958 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:37:53 +0200 Subject: [PATCH 5/8] fix(sdks): raise SandboxError/SandboxException for the metadata version gate Use SandboxError (JS) / SandboxException (Python) instead of TemplateError/TemplateException when metadata is set against envd older than 0.6.2. Co-Authored-By: Claude Opus 4.8 --- packages/js-sdk/src/sandbox/filesystem/index.ts | 3 ++- packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py | 2 +- packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 03fea68855..915c406c69 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -37,6 +37,7 @@ import { import { FileNotFoundError, InvalidArgumentError, + SandboxError, TemplateError, } from '../../errors' import { toBlob, toUploadBody } from '../../utils' @@ -472,7 +473,7 @@ export class Filesystem { Object.keys(metadata).length > 0 && compareVersions(this.envdApi.version, ENVD_FILE_METADATA) < 0 ) { - throw new TemplateError('File metadata requires envd 0.6.2 or later.') + throw new SandboxError('File metadata requires envd 0.6.2 or later.') } // Metadata is sent as request-scoped `X-Metadata-*` headers, so the same // metadata is applied to every file in a multi-file upload. diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 4105e86d76..75f6a2a162 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -266,7 +266,7 @@ async def write_files( return [] if metadata and self._envd_version < ENVD_FILE_METADATA: - raise TemplateException("File metadata requires envd 0.6.2 or later.") + raise SandboxException("File metadata requires envd 0.6.2 or later.") supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index 096fbd69e2..4b887f2941 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -262,7 +262,7 @@ def write_files( return [] if metadata and self._envd_version < ENVD_FILE_METADATA: - raise TemplateException("File metadata requires envd 0.6.2 or later.") + raise SandboxException("File metadata requires envd 0.6.2 or later.") supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream From 83961d29020b5026a246f158a9b9f8a8981bb16c Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:50:51 +0200 Subject: [PATCH 6/8] fix(sdks): use TemplateError/TemplateException for the metadata version gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox is healthy — it's the template's envd that is too old — so revert the gate back to TemplateError (JS) / TemplateException (Python), matching the recursive-watch version gate. Co-Authored-By: Claude Opus 4.8 --- packages/js-sdk/src/sandbox/filesystem/index.ts | 3 +-- packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py | 2 +- packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 915c406c69..03fea68855 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -37,7 +37,6 @@ import { import { FileNotFoundError, InvalidArgumentError, - SandboxError, TemplateError, } from '../../errors' import { toBlob, toUploadBody } from '../../utils' @@ -473,7 +472,7 @@ export class Filesystem { Object.keys(metadata).length > 0 && compareVersions(this.envdApi.version, ENVD_FILE_METADATA) < 0 ) { - throw new SandboxError('File metadata requires envd 0.6.2 or later.') + throw new TemplateError('File metadata requires envd 0.6.2 or later.') } // Metadata is sent as request-scoped `X-Metadata-*` headers, so the same // metadata is applied to every file in a multi-file upload. diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 75f6a2a162..4105e86d76 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -266,7 +266,7 @@ async def write_files( return [] if metadata and self._envd_version < ENVD_FILE_METADATA: - raise SandboxException("File metadata requires envd 0.6.2 or later.") + raise TemplateException("File metadata requires envd 0.6.2 or later.") supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index 4b887f2941..096fbd69e2 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -262,7 +262,7 @@ def write_files( return [] if metadata and self._envd_version < ENVD_FILE_METADATA: - raise SandboxException("File metadata requires envd 0.6.2 or later.") + raise TemplateException("File metadata requires envd 0.6.2 or later.") supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD use_octet_stream = use_octet_stream and supports_octet_stream From a4e5d86f49e75dc402526c910a1e891b075375b2 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:16:14 +0200 Subject: [PATCH 7/8] docs(sdks): clarify file metadata semantics and allowed key/value chars - EntryInfo/WriteInfo.metadata: reads also surface user.e2b.* xattrs set out-of-band, not only metadata supplied on upload (addresses review). - Allowed values: keys are sent as X-Metadata- HTTP headers, so they must be valid header tokens (not arbitrary printable ASCII) and are lowercased by the sandbox; values must be printable US-ASCII. Co-Authored-By: Claude Opus 4.8 --- .changeset/file-metadata-xattrs.md | 2 +- packages/js-sdk/src/sandbox/filesystem/index.ts | 16 +++++++++------- .../e2b/sandbox/filesystem/filesystem.py | 7 ++++--- .../e2b/sandbox_async/filesystem/filesystem.py | 4 ++-- .../e2b/sandbox_sync/filesystem/filesystem.py | 4 ++-- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.changeset/file-metadata-xattrs.md b/.changeset/file-metadata-xattrs.md index 209abe4c72..def09761ff 100644 --- a/.changeset/file-metadata-xattrs.md +++ b/.changeset/file-metadata-xattrs.md @@ -5,4 +5,4 @@ feat(sdks): expose user-defined file metadata on `sandbox.files` -Adds a `metadata` option to file uploads (`write` / `writeFiles` / `write_files`) and surfaces persisted metadata on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.e2b.` xattr namespace and returns them on subsequent filesystem reads. Keys and values must be printable US-ASCII and keys are lowercased by the sandbox; the same metadata map is applied to every file in a multi-file upload. Requires envd 0.6.2 or later. +Adds a `metadata` option to file uploads (`write` / `writeFiles` / `write_files`) and surfaces persisted metadata on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.e2b.` xattr namespace and returns them on subsequent filesystem reads (including `user.e2b.*` xattrs set out-of-band). Keys are sent as HTTP header names, so they must be valid header tokens and are lowercased by the sandbox; values must be printable US-ASCII. The same metadata map is applied to every file in a multi-file upload. Requires envd 0.6.2 or later. diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 03fea68855..388873b12e 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -79,9 +79,10 @@ export interface WriteInfo { */ path: string /** - * User-defined metadata persisted on the file as extended attributes. - * Only populated when metadata was supplied on upload and the sandbox's - * envd supports it. `undefined` when no metadata is set. + * User-defined metadata stored on the file as `user.e2b.*` extended + * attributes. On writes this reflects the metadata supplied on upload; on + * reads (`getInfo`, `list`, `rename`) it reflects any `user.e2b.*` xattr on + * the file, including ones set out-of-band. `undefined` when none is set. */ metadata?: Record } @@ -209,10 +210,11 @@ export interface FilesystemWriteOpts extends FilesystemRequestOpts { useOctetStream?: boolean /** * User-defined metadata to persist on the uploaded file(s) as extended - * attributes. Keys and values must be printable US-ASCII and keys are - * lowercased by the sandbox, so they may differ in case when read back. - * The same metadata is applied to every file in a multi-file upload. - * Requires envd 0.6.2 or later. + * attributes. Each entry is sent as an `X-Metadata-` request header, so + * keys must be valid HTTP header tokens (letters, digits and ``!#$%&'*+-.^_`|~``) + * and are lowercased by the sandbox, so they may differ in case when read + * back; values must be printable US-ASCII. The same metadata is applied to + * every file in a multi-file upload. Requires envd 0.6.2 or later. */ metadata?: Record } diff --git a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py index 90d48dc7a7..daf5f5c023 100644 --- a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py @@ -51,9 +51,10 @@ class WriteInfo: """ metadata: Optional[Dict[str, str]] = field(default=None, kw_only=True) """ - User-defined metadata persisted on the file as extended attributes. - Only populated when metadata was supplied on upload and the sandbox's - envd supports it. `None` when no metadata is set. + User-defined metadata stored on the file as `user.e2b.*` extended + attributes. On writes this reflects the metadata supplied on upload; on + reads (`get_info`, `list`, `rename`) it reflects any `user.e2b.*` xattr on + the file, including ones set out-of-band. `None` when none is set. """ @classmethod diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index e5c0284981..7dd82b1614 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -215,7 +215,7 @@ async def write( :param request_timeout: Timeout for the request in **seconds** :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys must be US-ASCII. Requires envd 0.6.2 or later. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Each entry is sent as an `X-Metadata-` request header, so keys must be valid HTTP header tokens and are lowercased by the sandbox; values must be printable US-ASCII. Requires envd 0.6.2 or later. :return: Information about the written file """ @@ -255,7 +255,7 @@ async def write_files( :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Keys must be US-ASCII. Requires envd 0.6.2 or later. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes; the same map is applied to every file. Each entry is sent as an `X-Metadata-` request header, so keys must be valid HTTP header tokens and are lowercased by the sandbox; values must be printable US-ASCII. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index 3e639ec0c8..5adcdfdf15 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -213,7 +213,7 @@ def write( :param request_timeout: Timeout for the request in **seconds** :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys must be US-ASCII. Requires envd 0.6.2 or later. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Each entry is sent as an `X-Metadata-` request header, so keys must be valid HTTP header tokens and are lowercased by the sandbox; values must be printable US-ASCII. Requires envd 0.6.2 or later. :return: Information about the written file """ @@ -253,7 +253,7 @@ def write_files( :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on each uploaded file as extended attributes. The same map is applied to every file. Keys must be US-ASCII. Requires envd 0.6.2 or later. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes; the same map is applied to every file. Each entry is sent as an `X-Metadata-` request header, so keys must be valid HTTP header tokens and are lowercased by the sandbox; values must be printable US-ASCII. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user From abe041990769e597e45ce85b7b1b2a3985eff3fc Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:30:17 +0200 Subject: [PATCH 8/8] feat(sdks): validate file metadata keys/values client-side Reverts the verbose "allowed values" doc wording and instead enforces it: add validateMetadata / validate_metadata that reject keys which aren't valid HTTP header tokens (incl. empty) and values that aren't printable US-ASCII, raising InvalidArgumentError / InvalidArgumentException before any request. Wired into write/write_files (JS + Python sync/async) ahead of the version gate, with negative tests in all three suites. Co-Authored-By: Claude Opus 4.8 --- .changeset/file-metadata-xattrs.md | 2 +- .../js-sdk/src/sandbox/filesystem/index.ts | 36 ++++++++++++++++--- .../tests/sandbox/files/metadata.test.ts | 25 ++++++++++++- .../e2b/sandbox/filesystem/filesystem.py | 23 ++++++++++++ .../sandbox_async/filesystem/filesystem.py | 7 ++-- .../e2b/sandbox_sync/filesystem/filesystem.py | 7 ++-- .../sandbox_async/files/test_metadata.py | 22 ++++++++++++ .../sync/sandbox_sync/files/test_metadata.py | 24 +++++++++++++ 8 files changed, 135 insertions(+), 11 deletions(-) diff --git a/.changeset/file-metadata-xattrs.md b/.changeset/file-metadata-xattrs.md index def09761ff..d8da10393a 100644 --- a/.changeset/file-metadata-xattrs.md +++ b/.changeset/file-metadata-xattrs.md @@ -5,4 +5,4 @@ feat(sdks): expose user-defined file metadata on `sandbox.files` -Adds a `metadata` option to file uploads (`write` / `writeFiles` / `write_files`) and surfaces persisted metadata on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.e2b.` xattr namespace and returns them on subsequent filesystem reads (including `user.e2b.*` xattrs set out-of-band). Keys are sent as HTTP header names, so they must be valid header tokens and are lowercased by the sandbox; values must be printable US-ASCII. The same metadata map is applied to every file in a multi-file upload. Requires envd 0.6.2 or later. +Adds a `metadata` option to file uploads (`write` / `writeFiles` / `write_files`) and surfaces persisted metadata on every `EntryInfo` / `WriteInfo` returned by `getInfo`, `list`, `rename`, and write responses. On upload, metadata is sent as `X-Metadata-: ` request headers; envd persists the values as extended attributes in the `user.e2b.` xattr namespace and returns them on subsequent filesystem reads (including `user.e2b.*` xattrs set out-of-band). Keys are sent as HTTP header names and are lowercased by the sandbox; metadata is validated client-side (keys must be valid HTTP header tokens, values must be printable US-ASCII) and invalid input raises `InvalidArgumentError` / `InvalidArgumentException`. The same metadata map is applied to every file in a multi-file upload. Requires envd 0.6.2 or later. diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 388873b12e..3fc0f417cd 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -170,6 +170,32 @@ function mapMetadata( const METADATA_HEADER_PREFIX = 'X-Metadata-' +// Metadata keys travel as `X-Metadata-` HTTP header names, so they must be +// valid header tokens (RFC 7230); values travel as header values, restricted to +// printable US-ASCII. +const METADATA_KEY_REGEX = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/ +const METADATA_VALUE_REGEX = /^[\x20-\x7e]*$/ + +function validateMetadata(metadata: Record | undefined): void { + if (!metadata) return + for (const [key, value] of Object.entries(metadata)) { + if (!METADATA_KEY_REGEX.test(key)) { + throw new InvalidArgumentError( + `Invalid metadata key ${JSON.stringify( + key + )}: keys must be non-empty and use only HTTP token characters (letters, digits and !#$%&'*+-.^_\`|~).` + ) + } + if (!METADATA_VALUE_REGEX.test(value)) { + throw new InvalidArgumentError( + `Invalid metadata value for key ${JSON.stringify( + key + )}: values must be printable US-ASCII.` + ) + } + } +} + function metadataHeaders( metadata: Record | undefined ): Record { @@ -210,11 +236,10 @@ export interface FilesystemWriteOpts extends FilesystemRequestOpts { useOctetStream?: boolean /** * User-defined metadata to persist on the uploaded file(s) as extended - * attributes. Each entry is sent as an `X-Metadata-` request header, so - * keys must be valid HTTP header tokens (letters, digits and ``!#$%&'*+-.^_`|~``) - * and are lowercased by the sandbox, so they may differ in case when read - * back; values must be printable US-ASCII. The same metadata is applied to - * every file in a multi-file upload. Requires envd 0.6.2 or later. + * attributes. Keys are lowercased by the sandbox, so they may differ in case + * when read back. Invalid keys or values throw an `InvalidArgumentError`. + * The same metadata is applied to every file in a multi-file upload. + * Requires envd 0.6.2 or later. */ metadata?: Record } @@ -469,6 +494,7 @@ export class Filesystem { (writeOpts?.useOctetStream ?? false) && supportsOctetStream const metadata = writeOpts?.metadata + validateMetadata(metadata) if ( metadata && Object.keys(metadata).length > 0 && diff --git a/packages/js-sdk/tests/sandbox/files/metadata.test.ts b/packages/js-sdk/tests/sandbox/files/metadata.test.ts index 7961573942..2ccd7dc55b 100644 --- a/packages/js-sdk/tests/sandbox/files/metadata.test.ts +++ b/packages/js-sdk/tests/sandbox/files/metadata.test.ts @@ -1,6 +1,7 @@ -import { assert } from 'vitest' +import { assert, expect } from 'vitest' import { WriteEntry } from '../../../src/sandbox/filesystem' +import { InvalidArgumentError } from '../../../src/errors.js' import { isDebug, sandboxTest } from '../../setup.js' sandboxTest('write file with metadata', async ({ sandbox }) => { @@ -160,3 +161,25 @@ sandboxTest( } } ) + +sandboxTest('rejects invalid metadata', async ({ sandbox }) => { + const filename = 'invalid_metadata.txt' + + // Key with a space is not a valid HTTP header token. + await expect( + sandbox.files.write(filename, 'x', { metadata: { 'bad key': 'value' } }) + ).rejects.toThrow(InvalidArgumentError) + + // Empty key. + await expect( + sandbox.files.write(filename, 'x', { metadata: { '': 'value' } }) + ).rejects.toThrow(InvalidArgumentError) + + // Value with a non-printable / non-ASCII character. + await expect( + sandbox.files.write(filename, 'x', { metadata: { good: 'bad\nvalue' } }) + ).rejects.toThrow(InvalidArgumentError) + + // The file must not have been created by a rejected write. + assert.isFalse(await sandbox.files.exists(filename)) +}) diff --git a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py index daf5f5c023..83fff10d26 100644 --- a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py @@ -1,4 +1,5 @@ import gzip +import re from dataclasses import dataclass, field from datetime import datetime from enum import Enum @@ -147,6 +148,28 @@ def to_upload_body( METADATA_HEADER_PREFIX = "X-Metadata-" +# Metadata keys travel as `X-Metadata-` HTTP header names, so they must be +# valid header tokens (RFC 7230); values travel as header values, restricted to +# printable US-ASCII. +_METADATA_KEY_REGEX = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$") +_METADATA_VALUE_REGEX = re.compile(r"^[\x20-\x7e]*$") + + +def validate_metadata(metadata: Optional[Dict[str, str]]) -> None: + """Validate metadata keys/values before they are sent as upload headers.""" + if not metadata: + return + for key, value in metadata.items(): + if not _METADATA_KEY_REGEX.match(key): + raise InvalidArgumentException( + f"Invalid metadata key {key!r}: keys must be non-empty and use only " + "HTTP token characters (letters, digits and !#$%&'*+-.^_`|~)." + ) + if not _METADATA_VALUE_REGEX.match(value): + raise InvalidArgumentException( + f"Invalid metadata value for key {key!r}: values must be printable US-ASCII." + ) + def metadata_to_headers( metadata: Optional[Dict[str, str]], diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 7dd82b1614..d5f3ee73d8 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -38,6 +38,7 @@ map_metadata, metadata_to_headers, to_upload_body, + validate_metadata, ) from e2b.sandbox.filesystem.watch_handle import FilesystemEvent from e2b.sandbox_async.filesystem.watch_handle import AsyncWatchHandle @@ -215,7 +216,7 @@ async def write( :param request_timeout: Timeout for the request in **seconds** :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Each entry is sent as an `X-Metadata-` request header, so keys must be valid HTTP header tokens and are lowercased by the sandbox; values must be printable US-ASCII. Requires envd 0.6.2 or later. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later. :return: Information about the written file """ @@ -255,7 +256,7 @@ async def write_files( :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on each uploaded file as extended attributes; the same map is applied to every file. Each entry is sent as an `X-Metadata-` request header, so keys must be valid HTTP header tokens and are lowercased by the sandbox; values must be printable US-ASCII. Requires envd 0.6.2 or later. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes; the same map is applied to every file. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user @@ -265,6 +266,8 @@ async def write_files( if len(files) == 0: return [] + validate_metadata(metadata) + if metadata and self._envd_version < ENVD_FILE_METADATA: raise TemplateException("File metadata requires envd 0.6.2 or later.") diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index 5adcdfdf15..707f33f93b 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -38,6 +38,7 @@ map_metadata, metadata_to_headers, to_upload_body, + validate_metadata, ) from e2b.sandbox_sync.filesystem.watch_handle import WatchHandle @@ -213,7 +214,7 @@ def write( :param request_timeout: Timeout for the request in **seconds** :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Each entry is sent as an `X-Metadata-` request header, so keys must be valid HTTP header tokens and are lowercased by the sandbox; values must be printable US-ASCII. Requires envd 0.6.2 or later. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later. :return: Information about the written file """ @@ -253,7 +254,7 @@ def write_files( :param request_timeout: Timeout for the request :param gzip: Use gzip compression for the request :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `False`. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. - :param metadata: User-defined metadata to persist on each uploaded file as extended attributes; the same map is applied to every file. Each entry is sent as an `X-Metadata-` request header, so keys must be valid HTTP header tokens and are lowercased by the sandbox; values must be printable US-ASCII. Requires envd 0.6.2 or later. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes; the same map is applied to every file. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later. :return: Information about the written files """ username = user @@ -263,6 +264,8 @@ def write_files( if len(files) == 0: return [] + validate_metadata(metadata) + if metadata and self._envd_version < ENVD_FILE_METADATA: raise TemplateException("File metadata requires envd 0.6.2 or later.") diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py index 23e4119f51..a8e37de0cb 100644 --- a/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py @@ -1,4 +1,7 @@ +import pytest + from e2b import AsyncSandbox +from e2b.exceptions import InvalidArgumentException from e2b.sandbox.filesystem.filesystem import WriteEntry @@ -139,3 +142,22 @@ async def test_metadata_set_via_xattrs_surfaced_in_get_info( if debug: await async_sandbox.files.remove(filename) + + +async def test_write_rejects_invalid_metadata(async_sandbox: AsyncSandbox): + filename = "invalid_metadata.txt" + + # Key with a space is not a valid HTTP header token. + with pytest.raises(InvalidArgumentException): + await async_sandbox.files.write(filename, "x", metadata={"bad key": "value"}) + + # Empty key. + with pytest.raises(InvalidArgumentException): + await async_sandbox.files.write(filename, "x", metadata={"": "value"}) + + # Value with a non-printable / non-ASCII character. + with pytest.raises(InvalidArgumentException): + await async_sandbox.files.write(filename, "x", metadata={"good": "bad\nvalue"}) + + # The file must not have been created by a rejected write. + assert not await async_sandbox.files.exists(filename) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py index fb66dad63a..8b1097def5 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py @@ -1,3 +1,8 @@ +import pytest + +from e2b.exceptions import InvalidArgumentException + + def test_write_file_with_metadata(sandbox, debug): filename = "test_metadata.txt" content = "This is a test file with metadata." @@ -128,3 +133,22 @@ def test_metadata_set_via_xattrs_surfaced_in_get_info(sandbox, debug): if debug: sandbox.files.remove(filename) + + +def test_write_rejects_invalid_metadata(sandbox): + filename = "invalid_metadata.txt" + + # Key with a space is not a valid HTTP header token. + with pytest.raises(InvalidArgumentException): + sandbox.files.write(filename, "x", metadata={"bad key": "value"}) + + # Empty key. + with pytest.raises(InvalidArgumentException): + sandbox.files.write(filename, "x", metadata={"": "value"}) + + # Value with a non-printable / non-ASCII character. + with pytest.raises(InvalidArgumentException): + sandbox.files.write(filename, "x", metadata={"good": "bad\nvalue"}) + + # The file must not have been created by a rejected write. + assert not sandbox.files.exists(filename)