diff --git a/apps/web/src/i18n/locales/en/files.ts b/apps/web/src/i18n/locales/en/files.ts index bf426cc53..d4cef3623 100644 --- a/apps/web/src/i18n/locales/en/files.ts +++ b/apps/web/src/i18n/locales/en/files.ts @@ -1,7 +1,7 @@ export const files = { title: "Files", help: "Files uploaded to this project through the Files API, including uploads made by API callers. Reference a File ID from an Environment template to copy the file into the workspace of each new Session. User data files cannot be downloaded: Core rejects content requests for them.", - limits: "Uploads accept one file of at most 512 MiB with a 1–1024 byte name; empty files are allowed.", + limits: "Uploads accept one file of at most 512 MiB with a 1–1024 byte name; empty files are rejected.", actions: { upload: "Upload file", uploading: "Uploading…", diff --git a/apps/web/src/i18n/locales/zh-CN/files.ts b/apps/web/src/i18n/locales/zh-CN/files.ts index ec42f72bd..1b9cbe8c5 100644 --- a/apps/web/src/i18n/locales/zh-CN/files.ts +++ b/apps/web/src/i18n/locales/zh-CN/files.ts @@ -5,7 +5,7 @@ type TranslationShape = { [K in keyof T]: T[K] extends string ? string : Tran export const files: TranslationShape = { title: "文件", help: "通过 Files API 上传到本项目的文件,包括 API 调用方上传的文件。在 Environment 模板中引用 File ID,即可把文件复制到每个新 Session 的工作区。用户数据(user_data)文件不能下载:Core 会拒绝读取其内容。", - limits: "每次上传一个文件,最大 512 MiB,文件名 1–1024 字节;允许空文件。", + limits: "每次上传一个文件,最大 512 MiB,文件名 1–1024 字节;不接受空文件。", actions: { upload: "上传文件", uploading: "上传中…", diff --git a/contracts/agents-api/source-files.md b/contracts/agents-api/source-files.md index 4ce6d05e3..6bfe8e65f 100644 --- a/contracts/agents-api/source-files.md +++ b/contracts/agents-api/source-files.md @@ -20,8 +20,8 @@ Use a File by passing its ID to [Environment files](./environment-files.md#creat ### Upload -- Only `purpose=user_data` is accepted. Other purposes, `expires_after` and the Uploads API are not supported. -- The file may be empty and holds up to 512 MiB; the whole multipart body may exceed that by 64 KiB. A larger upload returns 413 `request_too_large`. The transfer must finish within five minutes. +- Only `purpose=user_data` is accepted. Other purposes, `expires_after` and the Uploads API are not supported. A missing or other `purpose`, or a missing `file` part, returns 400 with `param: "purpose"` and a null code. +- The file holds at least one byte and up to 512 MiB; an empty file returns 400 `File is empty.` with a null code and param; the whole multipart body may exceed that by 64 KiB. A larger upload returns 413 `request_too_large`. The transfer must finish within five minutes. - A missing, repeated or unknown part, a `Content-Encoding` or `Content-Transfer-Encoding` header, or a filename that is empty, longer than 1,024 bytes, not UTF-8 or contains NUL returns 400. Core stores nothing until the whole request validates. - Core does not deduplicate uploads. After a lost response, list Files before uploading again. diff --git a/contracts/agents-api/zh/source-files.md b/contracts/agents-api/zh/source-files.md index 3eae82672..697b11ede 100644 --- a/contracts/agents-api/zh/source-files.md +++ b/contracts/agents-api/zh/source-files.md @@ -1,7 +1,7 @@ --- title: "文件与 Skill" source: contracts/agents-api/source-files.md -source_hash: 86d63f3ea780394ed99c0f5ec9d566117140a8650cb780e4b038e4ab45155ba9 +source_hash: 32041f334a2bf3141731fab69901fc97665da225e4e03328df0db7300faa18ff --- Files(`/v1/files`)和 Skills(`/v1/skills`)是具有独立生命周期的项目资源,不依赖 Session。File 保存上传字节,Environment 通过 ID 复制它们。Skill 保存不可变、版本化的包,由模板和 Session 引用。项目的所有 API 密钥共享这些资源。 @@ -22,8 +22,8 @@ Files(`/v1/files`)和 Skills(`/v1/skills`)是具有独立生命周期的 ### 上传 {#upload} -- 仅接受 `purpose=user_data`。不支持其他用途、`expires_after` 或 Uploads API。 -- 文件可为空,最大 512 MiB;整个 multipart 正文可额外增加 64 KiB。更大上传返回 413 `request_too_large`。传输须在五分钟内完成。 +- 仅接受 `purpose=user_data`。不支持其他用途、`expires_after` 或 Uploads API。缺少 `purpose` 或取其他值,或缺少 `file` 部分时,返回 400,`param: "purpose"`,code 为 null。 +- 文件至少一个字节,最大 512 MiB;空文件返回 400 `File is empty.`,code 与 param 为 null;整个 multipart 正文可额外增加 64 KiB。更大上传返回 413 `request_too_large`。传输须在五分钟内完成。 - 部分缺失、重复或未知,存在 `Content-Encoding` 或 `Content-Transfer-Encoding` 头,或文件名为空、超过 1,024 字节、非 UTF-8、包含 NUL 时返回 400。完整请求通过验证之前 Core 不存储任何内容。 - Core 不对上传去重。响应丢失后,先列出 File 再次上传。 diff --git a/services/core/internal/api/errors_files.go b/services/core/internal/api/errors_files.go index f5de3795c..f122cf0df 100644 --- a/services/core/internal/api/errors_files.go +++ b/services/core/internal/api/errors_files.go @@ -14,7 +14,11 @@ func writeFilesError(w http.ResponseWriter, r *http.Request, err error, notFound if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) { return } + var field *fieldError switch { + case errors.As(err, &field): + // The Files routes keep their non-beta error envelope. + writeError(w, http.StatusBadRequest, "", field.message, field.param) case errors.Is(err, files.ErrNotFound): code := "not_found_error" // The Files routes keep their non-beta error envelope. @@ -24,6 +28,8 @@ func writeFilesError(w http.ResponseWriter, r *http.Request, err error, notFound writeError(w, http.StatusNotFound, code, "Resource not found.", notFoundParam...) case errors.Is(err, files.ErrTooLarge): writeContentTooLarge(w) + case errors.Is(err, files.ErrEmpty): + writeError(w, http.StatusBadRequest, "", "File is empty.") case errors.Is(err, files.ErrInvalidInput): writeError(w, http.StatusBadRequest, "invalid_request", "Invalid resource identifier or request limits.") default: diff --git a/services/core/internal/api/source_files_test.go b/services/core/internal/api/source_files_test.go index 068ec1b48..9adcd126a 100644 --- a/services/core/internal/api/source_files_test.go +++ b/services/core/internal/api/source_files_test.go @@ -206,6 +206,31 @@ func TestSourceFilesRejectIncompleteOrUnsupportedMultipart(t *testing.T) { t.Fatalf("invalid multipart accepted %v: %d", fields, status) } } + for _, test := range []struct { + fields []string + data string + param any + message string + }{ + {[]string{"file"}, "x", "purpose", "Missing required parameter: 'purpose'."}, + {[]string{"file", "purpose=batch"}, "x", "purpose", "Only purpose user_data is supported."}, + {[]string{"purpose=user_data"}, "x", "purpose", "A file part is required."}, + {[]string{"file", "purpose=user_data"}, "", nil, "File is empty."}, + } { + f := &sourceFilesFixture{} + h, _ := environmentFileCreateHandler(t, f.wire) + server := httptest.NewServer(h) + body, contentType := sourceMultipart(t, test.fields, []byte(test.data)) + status, raw := sourceRequest(t, server, "POST", "/v1/files", "files-key", contentType, body) + server.Close() + var got map[string]map[string]any + if status != 400 || json.Unmarshal(raw, &got) != nil || f.file.ID != "" { + t.Fatalf("%v: %d %s", test.fields, status, raw) + } + if e := got["error"]; e["type"] != "invalid_request_error" || e["code"] != nil || e["param"] != test.param || e["message"] != test.message { + t.Fatalf("%v error projection: %s", test.fields, raw) + } + } f := &sourceFilesFixture{} h, _ := environmentFileCreateHandler(t, f.wire) server := httptest.NewServer(h) diff --git a/services/core/internal/api/source_files_upload.go b/services/core/internal/api/source_files_upload.go index 6aa23d2e5..22237eba0 100644 --- a/services/core/internal/api/source_files_upload.go +++ b/services/core/internal/api/source_files_upload.go @@ -68,8 +68,8 @@ func readSourceUpload(r *http.Request, dst io.Writer) (files.Upload, error) { return input, files.ErrInvalidInput } value, err := io.ReadAll(io.LimitReader(part, 65)) - if err != nil || len(value) > 64 { - return input, files.ErrInvalidInput + if err != nil { + return input, sourceUploadError(err) } input.Purpose = string(value) } @@ -80,8 +80,14 @@ func readSourceUpload(r *http.Request, dst io.Writer) (files.Upload, error) { if _, err := io.CopyBuffer(io.Discard, r.Body, buffer); err != nil { return input, sourceUploadError(err) } - if !seen["file"] || !seen["purpose"] || input.Purpose != files.PurposeUserData { - return input, files.ErrInvalidInput + switch { + case !seen["purpose"]: + return input, &fieldError{param: "purpose", message: "Missing required parameter: 'purpose'."} + case input.Purpose != files.PurposeUserData: + return input, &fieldError{param: "purpose", message: "Only purpose user_data is supported."} + case !seen["file"]: + // The official service names purpose for a missing file part too. + return input, &fieldError{param: "purpose", message: "A file part is required."} } return input, nil } diff --git a/services/core/internal/files/errors.go b/services/core/internal/files/errors.go index dbd9b126b..5ad4fe430 100644 --- a/services/core/internal/files/errors.go +++ b/services/core/internal/files/errors.go @@ -11,4 +11,6 @@ var ( ErrInvalidInput = errors.New("invalid file request") // ErrTooLarge reports content beyond the operation's limit. ErrTooLarge = errors.New("file exceeds the content limit") + // ErrEmpty reports an upload without content. + ErrEmpty = errors.New("file is empty") ) diff --git a/services/core/internal/files/service.go b/services/core/internal/files/service.go index 325f7ec31..fe99ad337 100644 --- a/services/core/internal/files/service.go +++ b/services/core/internal/files/service.go @@ -25,9 +25,9 @@ type CreateCommand struct { Upload func(io.Writer) (Upload, error) } -// Create stores the File only after its complete content fits MaxBytes and its -// envelope validates. A content write that failed, including one beyond -// MaxBytes, fails the upload even when Upload ignored the write error. +// Create stores the File only after its complete content is nonempty, fits +// MaxBytes and its envelope validates. A content write that failed, including +// one beyond MaxBytes, fails the upload even when Upload ignored the write error. func (s *Service) Create(ctx context.Context, command CreateCommand) (File, error) { if command.Upload == nil { return File{}, ErrInvalidInput @@ -44,6 +44,9 @@ func (s *Service) Create(ctx context.Context, command CreateCommand) (File, erro if err := upload.Validate(); err != nil { return Upload{}, err } + if content.left == MaxBytes { + return Upload{}, ErrEmpty + } return upload, nil }) } diff --git a/services/core/internal/files/service_test.go b/services/core/internal/files/service_test.go index a3c2a1200..6b257e7b2 100644 --- a/services/core/internal/files/service_test.go +++ b/services/core/internal/files/service_test.go @@ -78,6 +78,9 @@ func TestCreateRejectsWithoutStoring(t *testing.T) { return Upload{Filename: "a", Purpose: "assistants"}, nil }, ErrInvalidInput}, "upload failure": {func(io.Writer) (Upload, error) { return Upload{}, uploadFailure }, uploadFailure}, + "empty content": {func(io.Writer) (Upload, error) { + return Upload{Filename: "a", Purpose: PurposeUserData}, nil + }, ErrEmpty}, "content beyond the limit": {func(w io.Writer) (Upload, error) { _, err := w.Write(make([]byte, 1)) if err != nil { diff --git a/services/core/internal/persistence/postgres/filepg/filepg_test.go b/services/core/internal/persistence/postgres/filepg/filepg_test.go index 01dd1664c..019e55569 100644 --- a/services/core/internal/persistence/postgres/filepg/filepg_test.go +++ b/services/core/internal/persistence/postgres/filepg/filepg_test.go @@ -80,7 +80,7 @@ func TestFilesPersistScopeAndDelete(t *testing.T) { pool := pgtest.Open(t) store, service := open(t, pool) tenant, foreign := uuid.NewString(), uuid.NewString() - for _, data := range [][]byte{{}, {0, 1, 255}, bytes.Repeat([]byte("binary\x00"), 300000)} { + for _, data := range [][]byte{{0}, {0, 1, 255}, bytes.Repeat([]byte("binary\x00"), 300000)} { file := create(t, service, t.Context(), tenant, data) if file.SizeBytes != int64(len(data)) || file.Filename != "source.bin" || file.Purpose != files.PurposeUserData || !strings.HasPrefix(file.ID, "file-") || file.CreatedAt.IsZero() { t.Fatalf("create: %+v", file) diff --git a/services/core/internal/persistence/postgres/filepg/list_test.go b/services/core/internal/persistence/postgres/filepg/list_test.go index 767788493..0e80ee271 100644 --- a/services/core/internal/persistence/postgres/filepg/list_test.go +++ b/services/core/internal/persistence/postgres/filepg/list_test.go @@ -111,7 +111,7 @@ func TestFileListPaginationIsolationAndReconnect(t *testing.T) { if err != nil || !reflect.DeepEqual(foreignPage.Files, []files.File{foreign}) || foreignPage.NextCursor != "" { t.Fatalf("project isolation: %+v, %v", foreignPage, err) } - deleted := create(t, service, t.Context(), tenant, nil) + deleted := create(t, service, t.Context(), tenant, []byte("deleted")) if err := service.Delete(t.Context(), files.DeleteCommand{TenantID: tenant, FileID: deleted.ID}); err != nil { t.Fatal(err) }