Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/i18n/locales/en/files.ts
Original file line number Diff line number Diff line change
@@ -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…",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/i18n/locales/zh-CN/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ type TranslationShape<T> = { [K in keyof T]: T[K] extends string ? string : Tran
export const files: TranslationShape<typeof english> = {
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: "上传中…",
Expand Down
4 changes: 2 additions & 2 deletions contracts/agents-api/source-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions contracts/agents-api/zh/source-files.md
Original file line number Diff line number Diff line change
@@ -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 密钥共享这些资源。
Expand All @@ -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 再次上传。

Expand Down
6 changes: 6 additions & 0 deletions services/core/internal/api/errors_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions services/core/internal/api/source_files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 10 additions & 4 deletions services/core/internal/api/source_files_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions services/core/internal/files/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
9 changes: 6 additions & 3 deletions services/core/internal/files/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
})
}
Expand Down
3 changes: 3 additions & 0 deletions services/core/internal/files/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading