diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e2d347e74..1daad049e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1088,7 +1088,11 @@ qualify this layout. The read-only deployment needs neither writer setting.
Transfer a complete bounded body in acknowledged 64 KiB frames before invoking
the existing installer, verify the declared digest, and run no model for upload.
-Keep the existing private 50 MiB bound distinct from upstream protocol limits.
+Keep the private 50 MiB transfer bound distinct from the official 5 MiB decoded
+inline bound, which the API checks before any Runtime work. Files.create uses the
+installer's explicit create mode: parents are created without following links and
+an existing path is never replaced. Initial Session files and Skills keep the
+installer's replace mode; do not change one caller's mode for another.
The dedicated Runtime excludes execution while receiving or applying a write;
malformed, incomplete or expired transfers cannot reach the installer. Exact
commit/rejection receipts release the mutation owner. Missing or ambiguous
diff --git a/apps/parsar-daemon/internal/agent/workspace_write.go b/apps/parsar-daemon/internal/agent/workspace_write.go
index 77d69c939..237b73997 100644
--- a/apps/parsar-daemon/internal/agent/workspace_write.go
+++ b/apps/parsar-daemon/internal/agent/workspace_write.go
@@ -3,6 +3,7 @@ package agent
import (
"context"
"errors"
+ "fmt"
)
type WorkspaceWriteResult struct {
@@ -22,3 +23,10 @@ var (
ErrWorkspaceWriteRejected = errors.New("workspace write rejected")
ErrWorkspaceWriteUncertain = errors.New("workspace write outcome uncertain")
)
+
+// Known Files.create destination refusals. Both wrap ErrWorkspaceWriteRejected:
+// nothing was installed.
+var (
+ ErrWorkspaceWriteDirectory = fmt.Errorf("%w: destination is a directory", ErrWorkspaceWriteRejected)
+ ErrWorkspaceWriteUnsafe = fmt.Errorf("%w: destination exists or its path is not a plain directory chain", ErrWorkspaceWriteRejected)
+)
diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write.go b/apps/parsar-daemon/internal/dispatch/workspace_write.go
index 338e3bdd8..751cdb807 100644
--- a/apps/parsar-daemon/internal/dispatch/workspace_write.go
+++ b/apps/parsar-daemon/internal/dispatch/workspace_write.go
@@ -137,6 +137,17 @@ func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int)
if err == nil && write.SizeBytes == int64(size) {
return proto.WorkspaceWriteResultPayload{Outcome: "completed", SizeBytes: size}
}
+ for _, conflict := range []struct {
+ err error
+ reason string
+ }{
+ {agent.ErrWorkspaceWriteDirectory, proto.WorkspaceWriteReasonDirectory},
+ {agent.ErrWorkspaceWriteUnsafe, proto.WorkspaceWriteReasonUnsafe},
+ } {
+ if errors.Is(err, conflict.err) {
+ return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: "write_rejected", Reason: conflict.reason}
+ }
+ }
for _, failure := range []struct {
err error
code string
diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go
index d33f46688..d3d71f805 100644
--- a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go
+++ b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go
@@ -165,3 +165,29 @@ func TestLocalUploadUnknownRetainsOwner(t *testing.T) {
t.Fatal("shutdown declared uncertain mutation settled")
}
}
+
+func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) {
+ for helperError, reason := range map[string]string{
+ "destination_directory": proto.WorkspaceWriteReasonDirectory,
+ "unsafe_destination": proto.WorkspaceWriteReasonUnsafe,
+ "write_failed": "",
+ } {
+ t.Run(helperError, func(t *testing.T) {
+ r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"failed","error":"`+helperError+`"}`)
+ id := uuid.NewString()
+ for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} {
+ if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := waitWorkspaceWrite(t, sender, id, "rejected"); got.ErrorCode != "write_rejected" || got.Reason != reason {
+ t.Fatal(got)
+ }
+ next := uuid.NewString()
+ if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, next, request)); err != nil {
+ t.Fatal(err)
+ }
+ waitWorkspaceWrite(t, sender, next, "ready")
+ })
+ }
+}
diff --git a/apps/parsar-daemon/internal/localworkspace/write.go b/apps/parsar-daemon/internal/localworkspace/write.go
index fc391c51a..15aaf726d 100644
--- a/apps/parsar-daemon/internal/localworkspace/write.go
+++ b/apps/parsar-daemon/internal/localworkspace/write.go
@@ -52,7 +52,9 @@ func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []by
operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 65*time.Second)
defer cancel()
digest := sha256.Sum256(data)
- cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging)
+ // This writer serves only public Files.create, so it always selects the
+ // helper's create mode: create missing parents and never replace.
+ cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging, "create")
cmd.Dir = "/"
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"}
cmd.Stdin = io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:]))
@@ -96,8 +98,15 @@ func decodeWrite(data []byte, expected int) (agent.WorkspaceWriteResult, error)
if wire.Outcome == "completed" && wire.Error == nil && wire.SizeBytes != nil && *wire.SizeBytes == int64(expected) {
return agent.WorkspaceWriteResult{SizeBytes: *wire.SizeBytes}, nil
}
- if wire.Outcome == "failed" && wire.SizeBytes == nil && wire.Error != nil && (*wire.Error == "invalid_input" || *wire.Error == "write_failed") {
- return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteRejected
+ if wire.Outcome == "failed" && wire.SizeBytes == nil && wire.Error != nil {
+ switch *wire.Error {
+ case "invalid_input", "write_failed":
+ return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteRejected
+ case "destination_directory":
+ return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteDirectory
+ case "unsafe_destination":
+ return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsafe
+ }
}
return agent.WorkspaceWriteResult{}, invalid
}
diff --git a/apps/parsar-daemon/internal/localworkspace/write_test.go b/apps/parsar-daemon/internal/localworkspace/write_test.go
index 70f83121b..774208332 100644
--- a/apps/parsar-daemon/internal/localworkspace/write_test.go
+++ b/apps/parsar-daemon/internal/localworkspace/write_test.go
@@ -60,7 +60,8 @@ func TestWriteRejectsUnsafeBindings(t *testing.T) {
func TestWriteReceiptAndCredentialBoundary(t *testing.T) {
t.Setenv("PARSAR_PRIVATE_CREDENTIAL", "synthetic-secret")
- b := writableBinding(t, "[ -z \"$PARSAR_PRIVATE_CREDENTIAL\" ] || exit 13\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n")
+ // Files.create always selects the helper's explicit create mode.
+ b := writableBinding(t, "[ -z \"$PARSAR_PRIVATE_CREDENTIAL\" ] || exit 13\n[ \"$#\" = 5 ] && [ \"$5\" = create ] || exit 14\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n")
result, err := b.WriteWorkspaceFile(t.Context(), "file", []byte{0, 1, 2})
if err != nil || result.SizeBytes != 3 {
t.Fatalf("write: %+v %v", result, err)
@@ -144,14 +145,22 @@ func TestWriteReceiptValidation(t *testing.T) {
`{"version":1,"outcome":"failed","error":"other"}`,
`{"version":1,"outcome":"completed","size_bytes":0} {}`,
`{"version":1,"outcome":"completed","size_bytes":0,"extra":true}`,
+ `{"version":1,"outcome":"unknown","error":"unsafe_destination"}`,
+ `{"version":1,"outcome":"failed","size_bytes":0,"error":"destination_directory"}`,
} {
if _, err := decodeWrite([]byte(response), 0); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) {
t.Fatalf("unsafe receipt %s: %v", response, err)
}
}
- for _, code := range []string{"invalid_input", "write_failed"} {
- if _, err := decodeWrite([]byte(fmt.Sprintf(`{"version":1,"outcome":"failed","error":%q}`, code)), 0); !errors.Is(err, agent.ErrWorkspaceWriteRejected) {
- t.Fatal(err)
+ for code, want := range map[string]error{
+ "invalid_input": agent.ErrWorkspaceWriteRejected,
+ "write_failed": agent.ErrWorkspaceWriteRejected,
+ "destination_directory": agent.ErrWorkspaceWriteDirectory,
+ "unsafe_destination": agent.ErrWorkspaceWriteUnsafe,
+ } {
+ _, err := decodeWrite([]byte(fmt.Sprintf(`{"version":1,"outcome":"failed","error":%q}`, code)), 0)
+ if err != want || !errors.Is(err, agent.ErrWorkspaceWriteRejected) {
+ t.Fatal(code, err)
}
}
}
@@ -165,40 +174,47 @@ func TestLocalWriteNativeInstaller(t *testing.T) {
if err := b.bindWriter(helper, b.writer.staging); err != nil {
t.Fatal(err)
}
+ escape := filepath.Join(b.workspace, "escape")
+ if err := os.Symlink(b.writer.staging, escape); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(b.workspace, "setup.txt"), []byte("setup"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ // Missing parents are created; an existing destination is never replaced.
for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} {
+ path := fmt.Sprintf("n1/n2/file-%d", size)
data := bytes.Repeat([]byte{0, 255, 17}, (size+2)/3)[:size]
- got, err := b.WriteWorkspaceFile(t.Context(), "file", data)
+ got, err := b.WriteWorkspaceFile(t.Context(), path, data)
if err != nil || got.SizeBytes != int64(size) {
t.Fatalf("size %d: %+v %v", size, got, err)
}
- actual, err := os.ReadFile(filepath.Join(b.workspace, "file"))
+ actual, err := os.ReadFile(filepath.Join(b.workspace, path))
if err != nil || !bytes.Equal(actual, data) {
t.Fatalf("size %d bytes differ: %v", size, err)
}
}
- old := filepath.Join(b.workspace, "file")
- if err := os.WriteFile(old, []byte("original"), 0600); err != nil {
- t.Fatal(err)
- }
- alias := filepath.Join(b.workspace, "alias")
- if err := os.Link(old, alias); err != nil {
- t.Fatal(err)
+ if info, err := os.Lstat(filepath.Join(b.workspace, "n1")); err != nil || !info.IsDir() || info.Mode().Perm() != 0700 {
+ t.Fatal("parent directory", info, err)
}
- if _, err := b.WriteWorkspaceFile(t.Context(), "file", []byte("replacement")); err != nil {
- t.Fatal(err)
- }
- if data, err := os.ReadFile(alias); err != nil || string(data) != "original" {
- t.Fatal("hard-link contents changed", err)
- }
- escape := filepath.Join(b.workspace, "escape")
- if err := os.Symlink(b.writer.staging, escape); err != nil {
- t.Fatal(err)
- }
- for _, path := range []string{"escape/new", "escape", "missing/file"} {
- if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); !errors.Is(err, agent.ErrWorkspaceWriteRejected) {
+ for path, want := range map[string]error{
+ "n1/n2/file-3": agent.ErrWorkspaceWriteUnsafe,
+ "setup.txt": agent.ErrWorkspaceWriteUnsafe,
+ "escape": agent.ErrWorkspaceWriteUnsafe,
+ "escape/new": agent.ErrWorkspaceWriteUnsafe,
+ "n1/n2": agent.ErrWorkspaceWriteDirectory,
+ "n1": agent.ErrWorkspaceWriteDirectory,
+ } {
+ if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); err != want {
t.Fatalf("%s: %v", path, err)
}
}
+ if data, err := os.ReadFile(filepath.Join(b.workspace, "n1/n2/file-3")); err != nil || !bytes.Equal(data, []byte{0, 255, 17}) {
+ t.Fatal("existing file changed", err)
+ }
+ if data, err := os.ReadFile(filepath.Join(b.workspace, "setup.txt")); err != nil || string(data) != "setup" {
+ t.Fatal("untracked file changed", err)
+ }
if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil {
t.Fatal("known rejection blocked next write", err)
}
diff --git a/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.test.tsx b/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.test.tsx
index 52ffafded..834abdc4c 100644
--- a/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.test.tsx
+++ b/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.test.tsx
@@ -44,7 +44,8 @@ describe("EnvironmentFileCreatePanel", () => {
expect(validInlineEnvironmentFilePath(`/workspace/${"界".repeat(1_363)}`)).toBe(false);
});
- it("enforces the decoded 50 MiB boundary and encodes multibyte bytes as standard Base64", () => {
+ it("enforces the decoded 5 MiB boundary and encodes multibyte bytes as standard Base64", () => {
+ expect(maxInlineEnvironmentFileBytes).toBe(5 * 1024 * 1024);
expect(validInlineEnvironmentFileByteLength(maxInlineEnvironmentFileBytes)).toBe(true);
expect(validInlineEnvironmentFileByteLength(maxInlineEnvironmentFileBytes + 1)).toBe(false);
expect(validInlineEnvironmentFileByteLength(-1)).toBe(false);
diff --git a/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.tsx b/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.tsx
index cd3d7feb1..035c1096d 100644
--- a/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.tsx
+++ b/apps/web/src/features/sessions/environment/EnvironmentFileCreatePanel.tsx
@@ -101,7 +101,7 @@ export function EnvironmentFileCreatePanel({
Add inline Workspace file
- Explicit local selection · one write attempt · 50 MiB maximum
+ Explicit local selection · one write attempt · 5 MiB maximum
@@ -121,7 +121,7 @@ export function EnvironmentFileCreatePanel({
) : null}
{!sizeValid ? (
- This file exceeds the 50 MiB inline-content limit.
+ This file exceeds the 5 MiB inline-content limit.
) : null}
diff --git a/apps/web/src/features/sessions/environment/environment-file-create.ts b/apps/web/src/features/sessions/environment/environment-file-create.ts
index f978387f8..c0c5be665 100644
--- a/apps/web/src/features/sessions/environment/environment-file-create.ts
+++ b/apps/web/src/features/sessions/environment/environment-file-create.ts
@@ -3,7 +3,8 @@ import type {
EnvironmentFile,
} from "@agents-core-web/agents-client";
-export const maxInlineEnvironmentFileBytes = 50 * 1024 * 1024;
+// Core applies the official 5 MiB decoded bound to inline data; Source File copies keep 50 MiB.
+export const maxInlineEnvironmentFileBytes = 5 * 1024 * 1024;
export const inlineEnvironmentFileReadFailure =
"The selected local file could not be read. The selection and destination are unchanged; nothing was sent to Core.";
@@ -61,7 +62,7 @@ export function validInlineEnvironmentFileByteLength(bytes: number): boolean {
*/
export function arrayBufferToStandardBase64(buffer: ArrayBuffer): string {
if (!validInlineEnvironmentFileByteLength(buffer.byteLength)) {
- throw new RangeError("Environment files must be at most 50 MiB.");
+ throw new RangeError("Inline Environment files must be at most 5 MiB.");
}
const bytes = new Uint8Array(buffer);
@@ -103,7 +104,7 @@ export async function submitInlineEnvironmentFile(
return {
kind: "validation_error",
draft,
- message: "The selected file must contain at most 50 MiB.",
+ message: "The selected file must contain at most 5 MiB.",
};
}
@@ -120,7 +121,7 @@ export async function submitInlineEnvironmentFile(
return {
kind: "validation_error",
draft,
- message: "The decoded file content must contain at most 50 MiB.",
+ message: "The decoded file content must contain at most 5 MiB.",
};
}
diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md
index dd64d73de..5c147d0cd 100644
--- a/contracts/agents-api/README.md
+++ b/contracts/agents-api/README.md
@@ -104,7 +104,7 @@ paths start at `/vaults`, not `/agents/vaults`.
| sessions.subagents.turns | retrieve, list | Implemented; child Turns carry the Session's Agent ID and are not Session Turns |
| sessions.subagents.turns.items | list | Implemented; scoped persisted reads |
| environments | retrieve | Three-harness colocated self-hosted implementation and qualified Docker hosted profiles: durable status and safe initial-file metadata; other installation inventory and full lifecycle parity remain gaps |
-| environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker workspaces; [user-managed enrollment](user-managed-runtime-v1.md) reuses the local implementation with separate real public acceptance. [Aligned](environment-files.md#wire-alignment--september-23-2026) the 201 status, page envelope, query keys, empty pages for non-directory paths on local workspace readers, sampled path/token errors and pending hosted rejection; recursion, parent creation, overwrite and other errors remain partial |
+| environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker workspaces; [user-managed enrollment](user-managed-runtime-v1.md) reuses the local implementation with separate real public acceptance. [Aligned](environment-files.md#wire-alignment--september-23-2026) the 201 status, page envelope, query keys, empty pages for non-directory paths on local workspace readers, sampled path/token errors and pending hosted rejection; [aligned](environment-files.md#write-semantics--september-23-2026) parent creation, no-replacement and the 5 MiB inline bound; recursion and other errors remain partial |
| environments.templates | create, retrieve, update, list, delete | [Reusable network, files, env/setup/packages, inline/referenced Skills and Session snapshots](environment-templates.md); other initialization and full semantics remain gaps |
| vaults | create, retrieve, list, delete | Create/retrieve/list/delete with independent tenant persistence, stored status filtering, atomic Credential cascade and frozen Session attachments; archive semantics and full hosted lifecycle parity remain missing |
| vaults.credentials | create, retrieve, update, list, delete | Static-bearer and OAuth create/retrieve/list/replacement/deletion with scoped encrypted storage and dispatch-time refresh; Session attachment and exact-URL HTTPS MCP binding; archive semantics and full hosted lifecycle parity remain missing |
diff --git a/contracts/agents-api/environment-files.md b/contracts/agents-api/environment-files.md
index dad9a53d5..94653a65c 100644
--- a/contracts/agents-api/environment-files.md
+++ b/contracts/agents-api/environment-files.md
@@ -87,10 +87,11 @@ filenames, URLs and filesystem paths cannot substitute for an ID. Both members
use the same destination writer. Required null/omitted fields, extra fields and
invalid Base64 are rejected; an unknown top-level field names itself as `param`
when its name is short and printable.
-Unknown query keys are ignored. Empty bytes are valid. Inline
-paths must be canonical and cannot name the workspace root; the parent must exist.
-The current destination limit is 50 MiB for either source, with bounded JSON and 64 KiB daemon
-frames. These are local limits and policies, not verified upstream restrictions.
+Unknown query keys are ignored. Empty bytes are valid. Paths must
+be canonical and cannot name the workspace root; missing parent directories are
+created. Inline data is limited to 5 MiB decoded, the official bound; a `file_id`
+copy keeps the local 50 MiB destination limit, with bounded JSON and 64 KiB daemon
+frames. See the [write semantics](#write-semantics--september-23-2026).
Creation uses the same tenant Environment lookup as listing. The Worker checks the
stored local profile, immutable exact device/Environment binding and live capability.
@@ -105,11 +106,10 @@ does not establish isolation or public hosted admission.
Before sending any bytes, persist the mutation identity and request digest under
the Session lock. Pending input/execution and another unresolved upload exclude a
new mutation. The Runtime receives the complete body, verifies its digest and uses
-the existing installer to replace the destination with a fresh mode-0600 inode.
-Existing hard-link aliases retain their original contents. Uploads do not create
-parent directories or preserve destination permissions; exact upstream overwrite
-and metadata behavior remain unverified. Later independent tool writes can change
-the installed file; the response does not promise a snapshot.
+the shared installer in its create mode: it creates missing parents and installs a
+fresh mode-0600 file without replacing or following any existing path. Later
+independent tool writes can change the installed file; the response does not
+promise a snapshot.
Only an exact committed/rejected receipt settles durable ownership. Caller detach,
connection loss, timeout or missing output cannot be treated as rejection. Unknown
@@ -225,16 +225,17 @@ Decisions:
The client and Web accept only the new envelope and 201.
Deferred and unchanged: recursive listing (HE-30); creating parent directories,
-overwrite and the 50 MiB inline limit (HE-11/12/15, which change the shared
-installer); the Environment retrieve `files[]` projection (HE-03); Environment
+overwrite and the inline limit (HE-11/12/15), since aligned by the
+[write semantics](#write-semantics--september-23-2026) batch; the Environment
+retrieve `files[]` projection (HE-03); Environment
events (HE-02); the `self_hosted` status value (HE-04); `self_hosted` Files
support, which the official service refuses and Core's daemon keeps; limit bounds,
the default path and ordering. Malformed query encoding (such as `?foo=%GG` or `;`
separators) stays a local rejection on this list, while the shared lists drop those
pairs; there is no official sample.
-The official conflict and size-limit messages and the deleted-Session 404 message
-are not adopted. The Claude SDK adapter directory reader, used only when a daemon has
-no local workspace binding, is unchanged: F5 and F6 there keep 404 for a missing
+The deleted-Session 404 message is not adopted; the write semantics batch adopts
+the conflict and size-limit messages. The Claude SDK adapter directory reader, used
+only when a daemon has no local workspace binding, is unchanged: F5 and F6 there keep 404 for a missing
path and 503 for a regular file or symlink. A Runtime image built before this batch
keeps the same 404/503 results until it is rebuilt.
@@ -246,3 +247,86 @@ links and a replaced root. The TS client and Web unit tests cover the envelope a
201. The opt-in official scripts replay the list and create rows through raw HTTP
and the pinned SDK. Live acceptance, the server gate and independent review are
recorded separately by the coordinator.
+
+## Write semantics — September 23, 2026
+
+The pin is unchanged: SDK 3.13.0, commit `d7c41ef`, `agents=v1`. This batch starts
+from main `1eb60c27` and aligns Files.create writes with the hosted-environment
+campaign scan, recorded privately in
+`~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/` (`findings.json`
+HE-11, 12, 13 and 15; raw records under `official/` labelled
+`fc02-nested-missing-parents`, `fc03-overwrite`, `fc06-5mib-plus-1`,
+`fc15-onto-directory`, `fc20-overwrite-untracked`, `fc21-through-symlink-dir` and
+`fc22-symlink-outside`), and with the "File limits" of the official Environment
+files guide saved beside them. The batch plan is
+`~/.parsar/remediation/20260923/workspace-file-writes/PLAN.md`.
+
+| Row | Case | Core behavior | Evidence (finding: request ID) |
+| --- | --- | --- | --- |
+| FW1 | Missing parent directories | 201. Each missing component is created with mode 0700, the initial-file installer's convention, below the held no-follow walk | HE-11: `req_69a44a96866a4b52b637a5a5530dcd3c` |
+| FW2 | The destination is a file that an earlier Files.create wrote | 400 `invalid_request_error`, param null, `environment.files paths must not traverse symlinks or overwrite existing files` (official: `file path conflicts with an existing environment file`). No bytes change | HE-12: `req_f661f17c5c714d829364e13da32db6b1` |
+| FW3 | The destination is another existing file, such as one created by setup, a native tool or the model, or any other non-directory entry | 400 with the FW2 message. No bytes change | HE-12 `fc20`: `req_1242efc5adaa4ac5a854d2973ec81cd2` |
+| FW4 | The destination is an existing directory | 400 `invalid_request_error`, param null, `file path conflicts with an existing environment file` | HE-12 `fc15`: `req_c835a06aa7d949e0ae7883ef83c3145b` |
+| FW5 | A symlink anywhere in the parent chain or as the destination, or a non-directory parent component | 400 with the FW2 message. No link is followed | HE-13: `req_0c2c3cbf9e9947d79921cd198f42e9a6`, `req_02f00ab8a6c348aca0bfe47ac3704b18` |
+| FW6 | Inline data above 5 MiB decoded | 400 `invalid_request_error`, param null, `environment.files[0].data exceeds the 5 MiB decoded limit`, before any Runtime work. Exactly 5 MiB is accepted; a `file_id` copy keeps the 50 MiB bound | HE-15: `req_088ba87e6e3e4436a7713015e37615b5`; guide file limits |
+| FW7 | The destination or a link appears between the checks and the install | Never replaced or followed; the FW2 message | Rust race tests; not sampled officially |
+| FW8 | Unknown outcome | The durable mutation gate and unknown handling are unchanged | — |
+| FW9 | `self_hosted` | The daemon's local workspace writer serves Files.create for Core-managed Docker and user-managed Runtimes alike, so the same rules apply | — |
+| FW10 | Initial Session files, Skills, Plugins and cold resume | Unchanged | — |
+| FW11 | Tenant isolation, missing or foreign Environment | Unchanged 404 | — |
+
+Decisions:
+
+- The shared `agents-api-codex-write` helper takes an explicit per-call mode. Only
+ the daemon's local workspace writer, which serves only Files.create, passes
+ `create`. Core's initial Session file installer and the Skill installer keep the
+ four-argument replace mode, which older Runtime images also understand. Plugins
+ use the separate capability installer, and completed recovery, including cold
+ resume, never reinstalls files. Their semantics are unchanged.
+- Parents are created only after the complete body is verified, so incomplete or
+ corrupt input creates nothing. Each new directory is reopened without following
+ links. The install itself never replaces: `renameat2(RENAME_NOREPLACE)`, or
+ `linkat` where the filesystem lacks that flag (best effort; no test forces that
+ trigger). A normal rejection happens before anything is created and leaves
+ nothing behind. Only a concurrent change, an I/O error or a device mismatch
+ after the parents were created can leave them as empty mode-0700 directories.
+- Core cannot tell a file that an earlier Files.create wrote from any other file.
+ Its durable write intent stores a digest of the path, size and content, not a
+ path ledger, and a tool can remove and recreate a file afterwards; no cheap,
+ race-safe lookup exists. FW2 therefore uses the untracked-file message, a
+ message-only difference. A directory destination always uses the conflict
+ message; the official sample was a directory created as a parent by an earlier
+ write, and other directories are unsampled. Non-directory parent components and
+ non-regular destinations are unsampled and use the FW2 message.
+- The installer reports a fixed code, the daemon sends `write_rejected` with an
+ optional `reason`, and Core settles the durable intent as `rejected`. A rejected
+ write therefore leaves no committed receipt, releases the mutation owner,
+ changes no bytes and reads but does not consume a Source File.
+- The inline bound is checked after path validation and Base64 decoding, before
+ the F9 provisioning check, Source File lookup and execution. The JSON body limit
+ still admits Base64 for up to 50 MiB, so larger inline bodies up to that size get
+ the official message; beyond it the local 413 remains.
+- A Runtime image built before this batch keeps replacement and the existing-parent
+ requirement, with the generic local 400, until it is rebuilt. An older Core
+ ignores the new `reason` and keeps the generic 400. A new daemon with an older
+ helper is refused as invalid input, without mutation.
+- The TypeScript client and Core Web reject inline data above 5 MiB before sending.
+ The client's strict Base64 check now scans linearly, because the previous pattern
+ overflowed the regular-expression stack on inputs near 4 MiB and blocked an exact
+ 5 MiB upload.
+
+Rust tests cover parent creation, existing files, directories, hard-link aliases,
+FIFOs, symlink leaves and parents, dangling and inside links, non-directory parents,
+races for the destination and a missing parent, a replaced held ancestor, the
+`linkat` fallback and the explicit mode selection. Daemon tests cover the local
+workspace writer's create mode and codes with a scripted helper; its native-helper
+case runs only when `PARSAR_TEST_LOCAL_WRITE_HELPER` names a built helper, so it is
+opt-in and was run for this batch by hand and through live Docker acceptance.
+Dispatch and gateway tests cover the rejection reason. Go handler tests cover FW6 and the error mapping. A real-PostgreSQL
+HTTP and Worker test covers the conflict messages, settled `rejected` intents with no
+committed receipt, an unconsumed Source File, the inline bound before any intent,
+tenant B isolation and an admitted successor. The opt-in
+`official_environment_files_create.py` adds the nested, repeated, directory and
+5 MiB + 1 rows, with an exact 5 MiB inline case while source copies keep 50 MiB.
+Live acceptance, the server gate and independent review are recorded separately by
+the coordinator.
diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md
index a61accbd5..251790f6c 100644
--- a/contracts/agents-api/environment-templates.md
+++ b/contracts/agents-api/environment-templates.md
@@ -394,7 +394,8 @@ the following existing Linux Runtime packaging requirements through Provider
- `/workspace` is the public workspace. `/environment/workspace` names the same
storage for trusted initialization; `/environment/staging` is private staging.
- `/usr/bin/python3 -I -S` runs the trusted, fd-anchored initial-file installer.
- It invokes the existing `/usr/local/bin/agents-api-codex-write` atomic writer.
+ It invokes the existing `/usr/local/bin/agents-api-codex-write` atomic writer in
+ its four-argument replace mode; only public Files.create uses the create mode.
That executable is a shared filesystem helper packaged for every harness; its
historical name does not select Codex or invoke native Codex tools.
- Confidential content travels on bounded stdin. Successful initialization needs
diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md
index ff02268e3..e03d6238a 100644
--- a/contracts/agents-api/official-semantics-alignment.md
+++ b/contracts/agents-api/official-semantics-alignment.md
@@ -52,6 +52,9 @@ credential values belong in the repository or task board.
The error mapping above must not be extrapolated to every status or resource.
- Template references with inline installation overrides, optional Skill version
semantics and the other active board entries remain outstanding.
+- Files.create cannot tell a file written by an earlier Files.create from any other
+ existing file, so both report the untracked-file message; see the
+ [write semantics](environment-files.md#write-semantics--september-23-2026).
- Native model defaults, tool combinations and unavailable usage counters retain
their documented multi-harness differences. Core does not reconstruct model
output, guess counters or introduce a second tool loop to manufacture equality.
diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml
index aa2602e9d..f5119a583 100644
--- a/contracts/agents-api/openapi.yaml
+++ b/contracts/agents-api/openapi.yaml
@@ -3311,11 +3311,13 @@ paths:
source file_id through the same write path. Unknown body fields are rejected
with their name as param. Basic public hosted creation requires explicit managed
Runtime configuration; an openai_hosted Environment that has not connected
- yet returns 400. A private 50 MiB decoded-content limit applies. The parent
- directory must exist. Replacement installs a new mode-0600 inode; upstream
- overwrite metadata semantics remain unverified. Idle writes exclude execution.
- Missing receipts return unavailable and retain a durable mutation gate without
- automatic replay. Error/timing parity with upstream remains unverified.
+ yet returns 400. Inline data is limited to 5 MiB decoded and a file_id copy
+ to 50 MiB. Missing parent directories are created with mode 0700 and the file
+ with mode 0600. An existing destination is never replaced; a directory, an
+ existing file or a path through a symlink or non-directory returns 400. Idle
+ writes exclude execution. Missing receipts return unavailable and retain a
+ durable mutation gate without automatic replay. Error/timing parity with upstream
+ remains unverified.
parameters:
- description: agents=v1
in: header
diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md
index b02cabeed..eb19363df 100644
--- a/contracts/agents-api/operation-evidence.md
+++ b/contracts/agents-api/operation-evidence.md
@@ -1,6 +1,6 @@
# Pinned operation evidence inventory — 2026-09-23
-Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, the whitespace input batch (P) admits whitespace-only message text, the list cursor error batch (CE) updates unresolved `after` cursor errors on every list, the input conflict batch (CF) gives every 409 type `conflict_error` and aligns Session input conflicts and tool result target errors, and the saved web_search batch (SW) saves every pinned `web_search` mode while Session admission keeps rejecting enabled search; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility.
+Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, the whitespace input batch (P) admits whitespace-only message text, the list cursor error batch (CE) updates unresolved `after` cursor errors on every list, the input conflict batch (CF) gives every 409 type `conflict_error` and aligns Session input conflicts and tool result target errors, and the saved web_search batch (SW) saves every pinned `web_search` mode while Session admission keeps rejecting enabled search, and the workspace file write batch (FW) aligns Files.create parent creation, no-replacement and the inline size bound; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility.
Baseline: `contracts/agents-api/upstream.json`, SDK **3.13.0**, upstream commit **d7c41efee1b0802b79f3f88a678ef2052b06e9ce**, `OpenAI-Beta: agents=v1`. AGENTS.md and relevant CONTRIBUTING.md compatibility, ownership and evidence rules govern this inventory.
@@ -46,6 +46,7 @@ Repository paths below are relative to the inspected worktree; private evidence
| Y | [Artifact capture and listing](official-semantics-alignment.md#artifact-capture-and-listing--september-23); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-50..62 with raw records under `official/` (labels `al01`–`al09`, `ar01`–`ar04`, `ac01`–`ac05`, `ad01`/`ad02`): three owned Sessions and two tiny Turns, all deleted; first official Artifact observations. Rows A1–A4 of that section: symlink skip, republication, list envelope and malformed filter. Rust link tests, real-PostgreSQL store/HTTP and pinned-SDK tests without a model; live acceptance is recorded with the batch. |
| CE | [List cursor errors](list-query-semantics.md#list-cursor-errors--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-01..06 (ERR-07..13 and ERR-17 match or record upstream failures) with raw records labelled `cur-*` in `official/results.json`, plus SAT-04 (campaign scan 3) and HE-57 (campaign scan 2): owned Agent, Session, Turn, Item, Subagent, Artifact, Template, Vault, Credential, File, Skill and Skill version cursors without a model. Rows C1–C5 and K1–K3 of that section. Go store, real-PostgreSQL HTTP tenant A/B and pinned-SDK tests; independent acceptance is recorded with the batch. |
| Z | [Session deletion lifecycle](official-semantics-alignment.md#session-deletion-lifecycle--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/sessions/findings.json` SES-29/30 with raw records under `official/` (`q5-delete-repeat`, `q5b-delete-while-in-progress`, `q5-delete-never-existed`, `q5-delete-while-running`, `q5-get-after-delete`). Rows D1–D5 of that section. Handler, real-PostgreSQL HTTP/store/lock-race, Worker, pinned-SDK, TypeScript client and Web tests without a model; live acceptance is recorded with the batch. |
+| FW | [Environment Files write semantics](environment-files.md#write-semantics--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-11, 12, 13 and 15 with raw records under `official/` (`fc02-nested-missing-parents`, `fc03-overwrite`, `fc06-5mib-plus-1`, `fc15-onto-directory`, `fc20-overwrite-untracked`, `fc21-through-symlink-dir`, `fc22-symlink-outside`) and the saved guide file limits. Rows FW1–FW11 of that section. Rust installer and race tests, daemon writer tests (the native-helper case is opt-in and was run by hand and by live Docker acceptance), gateway/dispatch, Go handler and real-PostgreSQL HTTP/Worker tests with tenant B, TypeScript client and Web tests, without a model; live acceptance is recorded with the batch. |
| CF | [Session input conflicts and result targets](official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-22/27 (raw `official/results.json`: `sessB-message-while-running`, `sessA-delete-while-waiting`) and `~/.parsar/remediation/20260923/campaign-scan-2/events-tools/findings.json` EVT-11/12/14 (raw `official/calls-s2.json`: `s2-result-unknown-call`, `s2-result-unknown-turn`, `s2-result-duplicate-after-terminal`, `s2-result-changed-after-terminal`, `s2-result-after-cancel`). Rows CF1–CF9 of that section. Go API bodies, real-PostgreSQL HTTP replay with tenant B and a no-write digest, pinned-SDK scripts; live acceptance is recorded with the batch. |
| SW | [Saved web_search modes](official-semantics-alignment.md#saved-web_search-modes--september-23); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` TV-05 (W01/W02) and `~/.parsar/remediation/20260923/saved-web-search/official/{results.json,ledger.jsonl}`: four owned Agents (all deleted) with create records `type-only`, `mode-null`, `mode-cached`, `mode-cached-full`, update records `update-disabled`, `update-omitted-low`, `update-live-domains-empty` and `retrieve`, plus a second probe of two Agents (deleted, 404 confirmed) with `location-partial-omitted` and `location-empty`, without a Session or model. Rows W1–W8 of that section. Go handler, real-PostgreSQL HTTP exact-bytes/no-write/tenant B, pinned-SDK, TypeScript client and Web tests; independent acceptance is recorded with the batch. |
@@ -81,7 +82,7 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa
| 24 | beta.agents.sessions.subagents.turns.list | P: persisted child history with the Session Agent ID; limit outside 1–100 rejects; a cursor that is not a Turn of the child is 400 ``Invalid resource ID in `after` `` | U SAT-08 `R03`; SAT-03 `R16`/`R17` rejections; CE SAT-04 `C06`, ERR-04 `cur-subturns-*` | B recorded Live paging/cold continuation; U DB identity and rejection; CE DB cursor matrix tenant A/B | Full concurrent/cancel ordering and accounting |
| 25 | beta.agents.sessions.subagents.turns.items.list | P: exact child-and-Turn Items; limit 0/above 100 clamp; a cursor outside the child Turn's Items is 400 ``Invalid session item ID in `after` `` | U SAT-02 `C05`; `R05`; CE ERR-03 `cur-subturnitems-*` | B recorded Live six-read matrix; U DB clamp, tenant B; CE DB cursor matrix tenant A/B | Complete union and live ordering, overlapping mutation/cursors |
| 26 | beta.agents.environments.retrieve | P: durable status, safe configured installation metadata | None located | D/I/K recorded native readiness and metadata | All lifecycle timing and installation inventory; configured metadata is not arbitrary workspace discovery |
-| 27 | beta.agents.environments.files.create | P: inline/source-file copy to qualified workspace; 201; observed path, unknown-field and provisioning errors | G HE-10 success/nested/empty, HE-16 path forms and unknown field, HE-18 pending | F/D/K recorded real copy, hashes/consumption/retention; G handler tests | 50 MiB local bound and no parent creation (official 5 MiB, creates parents); overwrite/conflict messages, other body errors and unknown-write semantics |
+| 27 | beta.agents.environments.files.create | P: inline/source-file copy to qualified workspace; 201; observed path, unknown-field and provisioning errors; missing parents created, existing paths never replaced, 5 MiB decoded inline bound with official conflict, symlink/overwrite and size errors | G HE-10 success/nested/empty, HE-16 path forms and unknown field, HE-18 pending; FW HE-11 nested, HE-12 overwrite/untracked/directory, HE-13 symlinks, HE-15 5 MiB + 1 | F/D/K recorded real copy, hashes/consumption/retention; G handler tests; FW Rust, daemon, handler and real-PostgreSQL tests | A file an earlier Files.create wrote gets the untracked-file message (no path ledger); unsampled non-directory parents and non-regular destinations; other body errors and unknown-write semantics |
| 28 | beta.agents.environments.files.list | P: direct regular-file directory, opaque cursor; `page` envelope; unknown keys ignored (malformed query encoding still rejected locally), repeated keys rejected; missing, file and symlink paths list empty without following on local workspace readers (the Claude SDK adapter reader keeps 404/503); cleaned-path, token and provisioning errors | G HE-32 envelope, HE-34/35 query keys, HE-36/37 empty pages, HE-38/39 path and token errors, HE-18 pending | F/D/K recorded live workspace listing; G handler, Worker and native helper tests | 1,024-entry prefilter bound; no recursion (HE-30); exact defaults, unsampled path forms and mutation invalidation unknown |
| 29 | beta.agents.environments.templates.create | P: reusable network/files/env/setup/packages/Skills/Plugins/capability config, 201; network rejections use `invalid_request_error` | R `template-create`; V nullable Skill selector projection; X SFT-20 | C DB; I/K recorded real frozen-reference initialization | Restricted forms and unqualified combinations; complete hosted initialization semantics |
| 30 | beta.agents.environments.templates.retrieve | P: safe resource read | R `template-read`, deleted owned read; V nullable Skill selector projection | C DB; I/K recorded reference workflow | Full field/default/redaction parity; no live-secret projection inference |
@@ -116,11 +117,11 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa
## Remaining gaps without task ordering
-1. **Public generic semantics:** sampled create/event/envelope/error/no-op corrections are merged. L aligns unknown/repeated list keys, sampled limit bounds and single-resource unknown keys. X gives malformed path IDs on every Beta, Files and Skills route the exact missing-resource response, reports metadata/name field errors with official code and param, maps Template network rejections to `invalid_request_error`, and rejects U+0000 in stored strings as a documented local limit (the official service stores it). G aligns Environment Files list query tolerance and the sampled Files.create/list errors. CE gives every Beta and Skill version list the observed unresolved-cursor error; Files, Skills and Environment Files cursors are unchanged. Other resource-by-resource omissions/null/default/error params, overflowing limits, list caps, concurrent mutation and deletion require separate evidence.
+1. **Public generic semantics:** sampled create/event/envelope/error/no-op corrections are merged. L aligns unknown/repeated list keys, sampled limit bounds and single-resource unknown keys. X gives malformed path IDs on every Beta, Files and Skills route the exact missing-resource response, reports metadata/name field errors with official code and param, maps Template network rejections to `invalid_request_error`, and rejects U+0000 in stored strings as a documented local limit (the official service stores it). G aligns Environment Files list query tolerance and the sampled Files.create/list errors; FW aligns Files.create parent creation, no-replacement and its conflict and size errors. CE gives every Beta and Skill version list the observed unresolved-cursor error; Files, Skills and Environment Files cursors are unchanged. Other resource-by-resource omissions/null/default/error params, overflowing limits, list caps, concurrent mutation and deletion require separate evidence.
2. **Session differences:** The Session admission batch removes idle `none` creation and empty metadata update. Local durable creation idempotency remains an explicit difference. P admits whitespace-only input verbatim, as observed officially; Claude SDK and MiniMax Code declare whitespace-only messages unsupported at admission. Session agent updates, newer Environment shapes and root Item turn_id are baseline-upgrade questions.
3. **Template/Skill composition:** shared env/files/setup/packages selection is covered by the composition batch; template-reference null network/capability lists are covered by the null-selection batch. Official derived capability-directory projection remains different. Skill content/default metadata and sole-version deletion are covered by file-resource-semantics.md; number reuse is an intentional difference; visibility and broader error behavior remain unverified.
4. **Execution coverage:** use T's qualified matrix, not a blanket missing-image/structured-output claim. MiniMax functions/service MCP, optional tool combinations, unsupported images/placements and broader native lifecycle are explicit restrictions. PTC omission retains approved native behavior; Claude/MiniMax public Usage remains null; child settlement cadence/native close limits remain visible. No second executor/model loop or guessed counters are justified.
-5. **Workspace and resources:** live Files bounds, recursion and parent creation (G aligns the sampled envelope, empty pages and path errors), artifact capture edges for hard links/special files and cancellation (Y aligns output symlinks, republication and the list envelope), full Environment metadata/lifecycle and Vault archive/in-flight-token semantics remain partial or unknown. Retired Core-managed E2B acceptance cannot qualify current user enrollment.
+5. **Workspace and resources:** live Files recursion (G aligns the sampled envelope, empty pages and path errors; FW aligns parent creation, no-replacement and the inline bound), artifact capture edges for hard links/special files and cancellation (Y aligns output symlinks, republication and the list envelope), full Environment metadata/lifecycle and Vault archive/in-flight-token semantics remain partial or unknown. Retired Core-managed E2B acceptance cannot qualify current user enrollment.
## Enumeration and wording mismatches
diff --git a/internal/agentdaemon/gateway/workspace_write.go b/internal/agentdaemon/gateway/workspace_write.go
index 42631b5ea..d614acfb3 100644
--- a/internal/agentdaemon/gateway/workspace_write.go
+++ b/internal/agentdaemon/gateway/workspace_write.go
@@ -79,6 +79,9 @@ func (s *Session) WriteWorkspaceFile(ctx context.Context, id string, request pro
}
func validWorkspaceWriteResult(r proto.WorkspaceWriteResultPayload, expected string, offset, size int) bool {
+ if r.Reason != "" && (r.Outcome != "rejected" || r.ErrorCode != "write_rejected") {
+ return false
+ }
if r.Outcome == "rejected" {
if r.Offset != 0 || r.SizeBytes != 0 {
return false
diff --git a/internal/agentdaemon/gateway/workspace_write_test.go b/internal/agentdaemon/gateway/workspace_write_test.go
index 4dc98b5ee..3f7c90af3 100644
--- a/internal/agentdaemon/gateway/workspace_write_test.go
+++ b/internal/agentdaemon/gateway/workspace_write_test.go
@@ -94,11 +94,24 @@ func TestWorkspaceWriteRejectsPrematureOrContradictoryReceipts(t *testing.T) {
{Outcome: "ready", Offset: 1},
{Outcome: "rejected", ErrorCode: "private-detail"},
{Outcome: "unknown", ErrorCode: "write_unconfirmed", SizeBytes: 4},
+ // A destination reason refines only a known installer rejection.
+ {Outcome: "ready", Reason: proto.WorkspaceWriteReasonUnsafe},
+ {Outcome: "rejected", ErrorCode: "invalid_request", Reason: proto.WorkspaceWriteReasonUnsafe},
+ {Outcome: "unknown", ErrorCode: "write_unconfirmed", Reason: proto.WorkspaceWriteReasonDirectory},
} {
if validWorkspaceWriteResult(result, "ready", 0, 4) {
t.Fatal("unsafe result", result)
}
}
+ if validWorkspaceWriteResult(proto.WorkspaceWriteResultPayload{Outcome: "completed", SizeBytes: 4, Reason: proto.WorkspaceWriteReasonUnsafe}, "completed", 0, 4) {
+ t.Fatal("reason accepted on a commit")
+ }
+ // Unrecognized reasons from newer daemons remain generic known rejections.
+ for _, reason := range []string{"", proto.WorkspaceWriteReasonDirectory, proto.WorkspaceWriteReasonUnsafe, "future_reason"} {
+ if !validWorkspaceWriteResult(proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: "write_rejected", Reason: reason}, "completed", 0, 4) {
+ t.Fatal("known rejection refused", reason)
+ }
+ }
if validWorkspaceWriteResult(proto.WorkspaceWriteResultPayload{Outcome: "received", Offset: 1}, "received", 2, 4) {
t.Fatal("wrong offset accepted")
}
diff --git a/internal/agentdaemon/proto/workspace_write.go b/internal/agentdaemon/proto/workspace_write.go
index 19afdbf45..13a594e30 100644
--- a/internal/agentdaemon/proto/workspace_write.go
+++ b/internal/agentdaemon/proto/workspace_write.go
@@ -34,8 +34,17 @@ type WorkspaceWriteResultPayload struct {
Offset int `json:"offset,omitempty"`
SizeBytes int `json:"size_bytes,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
+ // Reason optionally refines a write_rejected result. Older peers omit or
+ // ignore it and keep the generic rejection.
+ Reason string `json:"reason,omitempty"`
}
+// Files.create destination refusals carried in Reason.
+const (
+ WorkspaceWriteReasonDirectory = "destination_directory"
+ WorkspaceWriteReasonUnsafe = "unsafe_destination"
+)
+
func ValidWorkspaceWriteRequest(p WorkspaceWritePayload) bool {
if p.Step == "begin" {
for _, id := range []string{p.EnvironmentID, p.SessionID} {
diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts
index 312c709fc..0abb9d7e2 100644
--- a/packages/agents-client/src/client.test.ts
+++ b/packages/agents-client/src/client.test.ts
@@ -1798,6 +1798,16 @@ describe("OpenAIAgentsClient", () => {
await expect(missing.listEnvironmentFiles("environment", { path: "/workspace/missing" })).resolves.toEqual(empty);
});
+ it("accepts exactly 5 MiB of inline Environment file data", async () => {
+ const calls: FetchCall[] = [];
+ const size = 5 * 1024 * 1024;
+ const result = { environment_id: "environment", object: "agent.environment.file", path: "/workspace/big.bin", size_bytes: size };
+ const client = new OpenAIAgentsClient({ fetch: recordingFetch(jsonResponse(result, 201), calls) });
+ await expect(client.createEnvironmentFile("environment", { type: "inline", data: btoa("a".repeat(size)), path: result.path }))
+ .resolves.toEqual(result);
+ expect(calls).toHaveLength(1);
+ });
+
it("requires 201 Created for an Environment file and never retries another success status", async () => {
const calls: FetchCall[] = [];
const result = {
@@ -1828,6 +1838,11 @@ describe("OpenAIAgentsClient", () => {
data: "YR==",
path: "/workspace/file.txt",
})).rejects.toThrow("strict standard Base64");
+ await expect(client.createEnvironmentFile("environment", {
+ type: "inline",
+ data: btoa("a".repeat(5 * 1024 * 1024 + 1)),
+ path: "/workspace/file.txt",
+ })).rejects.toThrow("at most 5 MiB");
await expect(client.createEnvironmentFile("environment", {
type: "file_id",
file_id: "file-16e1f26e-8cf6-4272-9c31-d470b08d31af",
diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts
index 247baf7dd..f25b5f508 100644
--- a/packages/agents-client/src/client.ts
+++ b/packages/agents-client/src/client.ts
@@ -273,6 +273,8 @@ const canonicalUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[
const sourceFileIdPattern = /^file-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const maxSourceFileBytes = 512 * 1024 * 1024;
const maxEnvironmentFileBytes = 50 * 1024 * 1024;
+// Core applies the official 5 MiB decoded bound to inline data; file_id copies keep 50 MiB.
+const maxInlineEnvironmentFileBytes = 5 * 1024 * 1024;
const environmentTemplateFields = new Set([
"id", "object", "name", "network", "capability_directories", "packages",
"files", "plugins", "skills", "created_at", "updated_at",
@@ -1731,13 +1733,15 @@ function projectEnvironmentFile(
function strictBase64DecodedBytes(value: unknown): number | null {
if (typeof value !== "string") return null;
if (value === "") return 0;
- if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
+ // A flat character class stays linear; a grouped quantifier overflows the
+ // regular-expression stack on multi-megabyte inline data.
+ if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*$/.test(value.slice(0, value.length - padding))) {
return null;
}
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
- if (value.endsWith("==") && (alphabet.indexOf(value[value.length - 3] ?? "") & 15) !== 0) return null;
- if (value.endsWith("=") && !value.endsWith("==") && (alphabet.indexOf(value[value.length - 2] ?? "") & 3) !== 0) return null;
- const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
+ if (padding === 2 && (alphabet.indexOf(value[value.length - 3] ?? "") & 15) !== 0) return null;
+ if (padding === 1 && (alphabet.indexOf(value[value.length - 2] ?? "") & 3) !== 0) return null;
return (value.length / 4) * 3 - padding;
}
@@ -2373,7 +2377,9 @@ export class OpenAIAgentsClient implements AgentCore {
}
const decodedBytes = strictBase64DecodedBytes(input.data);
if (decodedBytes === null) throw new TypeError("Inline Environment file data must be strict standard Base64.");
- if (decodedBytes > maxEnvironmentFileBytes) throw new TypeError("Environment files must be at most 50 MiB.");
+ if (decodedBytes > maxInlineEnvironmentFileBytes) {
+ throw new TypeError("Inline Environment file data must decode to at most 5 MiB.");
+ }
expectedSize = decodedBytes;
} else if (input.type === "file_id") {
if (
diff --git a/packages/codex-executor/README.md b/packages/codex-executor/README.md
index b5b8c051a..0cb684ae2 100644
--- a/packages/codex-executor/README.md
+++ b/packages/codex-executor/README.md
@@ -89,24 +89,44 @@ when their backing filesystem matches; the helper must reject that layout.
It does not authorize callers or enable Files.create.
Arguments are the authorized absolute root, a nonempty relative file path,
-its declared byte count (0–50 MiB), and an existing absolute staging directory.
-The staging directory must be outside the workspace, not its ancestor, and on
-the destination filesystem. Symlink traversal and cross-filesystem replacement
-are rejected; there is no copy fallback. The former three-argument private CLI
-is no longer accepted. Stream those bytes in bounded native stdin
+its declared byte count (0–50 MiB), and an existing absolute staging directory,
+optionally followed by the mode `create`. The staging directory must be outside
+the workspace, not its ancestor, and on the destination filesystem. Symlink
+traversal and cross-filesystem replacement are rejected; there is no copy
+fallback. The former three-argument private CLI is no longer accepted. Stream those bytes in bounded native stdin
chunks, followed by their 32-byte binary SHA-256 digest. This is one private frame;
there is no second request on that process. The digest terminates the private frame without waiting for EOF.
Extra bytes after the frame are not consumed. A process/write accepted receipt
means queued input, not committed file contents.
-The helper reuses held-directory no-follow traversal, rejects existing nonregular
-targets and requires an existing parent. It writes a fresh mode-0600 temporary
-file in the held staging directory with existing rustix openat/renameat operations,
-checks the declared byte count and digest, syncs the file, then replaces the
-destination directory entry and syncs both directories. Existing hard links retain
-their original inode and contents. This
-private replacement policy does not preserve destination mode/ownership metadata
-or establish official overwrite semantics. The operator must protect staging and
+Each caller selects its mode explicitly. The four-argument form is the replace
+mode of trusted initialization: Core's initial Session file installer and the
+Skill installer use it, and older Runtime images understand only this form.
+Only the daemon's public Files.create writer passes `create`.
+
+The helper reuses held-directory no-follow traversal. It writes a fresh mode-0600
+temporary file in the held staging directory, checks the declared byte count and
+digest and syncs the file. In replace mode it requires an existing parent,
+rejects existing nonregular targets, then replaces the destination directory
+entry with renameat and syncs both directories. Existing hard links retain their
+original inode and contents; destination mode/ownership metadata is not preserved.
+
+Create mode never replaces anything. Before reading input it walks the existing
+parent components without following links and checks the destination when the
+parent exists. After the body is verified it creates each missing parent with
+mkdirat (mode 0700, as the initial-file installer does) relative to the held
+parent, syncs that parent and reopens the new directory without following links.
+It then installs with renameat2(RENAME_NOREPLACE); where the filesystem lacks that
+flag, linkat installs the staging inode without replacement and the staging name
+is unlinked as best-effort cleanup. An existing symlink or non-directory component,
+or an existing destination that is not a directory, reports `unsafe_destination`;
+an existing directory reports `destination_directory`. A destination or link that
+appears concurrently is never replaced or followed and reports
+`unsafe_destination`. A normal rejection happens before anything is created and
+leaves nothing behind; only such a race, an I/O error or a device mismatch after
+parent creation can leave empty mode-0700 directories.
+
+The operator must protect staging and
its ancestors from native tools and background processes. A dedicated staging
directory per Environment, with native tool write access limited to the workspace
and separate installer access, is the qualified mechanism. Directory naming or
@@ -118,8 +138,9 @@ to protected staging, but later writers can change the installed file. No snapsh
or exactly-once guarantee is implied.
One version-1 JSON response reports `outcome: completed` with `size_bytes`,
-`failed` before replacement, or `unknown` if either directory sync fails after replacement.
-Errors contain only a fixed safe code. Require a complete response plus observed
+`failed` before installation, or `unknown` if either directory sync fails after installation.
+Errors contain only a fixed safe code: `invalid_input` or `write_failed`, and in
+create mode also `destination_directory` or `unsafe_destination`. Require a complete response plus observed
native exit/output close; exit zero alone is insufficient. Input errors preserve
the old destination provided staging remains protected; independent workspace
writers can still change that destination themselves. Temporary-file cleanup
diff --git a/packages/codex-executor/src/bin/write.rs b/packages/codex-executor/src/bin/write.rs
index bd3f0c92c..ccbbfbfa6 100644
--- a/packages/codex-executor/src/bin/write.rs
+++ b/packages/codex-executor/src/bin/write.rs
@@ -6,12 +6,22 @@ mod write_file;
use serde_json::json;
use std::io::{self, Write};
use std::path::Path;
+use write_file::{InstallMode, Rejection};
+
+/// The four-argument form keeps the replace mode of trusted initialization,
+/// including Core-issued initial-file installs on older Runtime images. The
+/// public Files.create writer appends `create`.
+fn mode(args: &[String]) -> Option {
+ match args {
+ [_, _, _, _] => Some(InstallMode::Replace),
+ [_, _, _, _, mode] if mode == "create" => Some(InstallMode::Create),
+ _ => None,
+ }
+}
fn run() -> Result {
let args: Vec<_> = std::env::args().skip(1).collect();
- if args.len() != 4 {
- return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
- }
+ let mode = mode(&args).ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
let size = args[2]
.parse::()
.map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?;
@@ -21,15 +31,25 @@ fn run() -> Result {
size,
io::stdin().lock(),
Path::new(&args[3]),
+ mode,
)?;
Ok(size)
}
+fn error_code(failure: &write_file::Failure) -> &'static str {
+ match failure.rejection {
+ Some(Rejection::Directory) => "destination_directory",
+ Some(Rejection::Unsafe) => "unsafe_destination",
+ None if failure.error.kind() == io::ErrorKind::InvalidInput => "invalid_input",
+ None => "write_failed",
+ }
+}
+
fn main() -> std::process::ExitCode {
let response = match run() {
Ok(size) => json!({"version": 1, "outcome": "completed", "size_bytes": size}),
Err(failure) => {
- json!({"version": 1, "outcome": if failure.committed { "unknown" } else { "failed" }, "error": if failure.error.kind() == io::ErrorKind::InvalidInput { "invalid_input" } else { "write_failed" }})
+ json!({"version": 1, "outcome": if failure.committed { "unknown" } else { "failed" }, "error": error_code(&failure)})
}
};
if writeln!(io::stdout().lock(), "{response}").is_err() {
@@ -37,3 +57,47 @@ fn main() -> std::process::ExitCode {
}
std::process::ExitCode::SUCCESS
}
+
+#[cfg(test)]
+mod cli_tests {
+ use super::*;
+
+ #[test]
+ fn selects_mode_explicitly_and_reports_fixed_codes() {
+ let args = |values: &[&str]| values.iter().map(|v| v.to_string()).collect::>();
+ assert_eq!(
+ mode(&args(&["r", "p", "0", "s"])),
+ Some(InstallMode::Replace)
+ );
+ assert_eq!(
+ mode(&args(&["r", "p", "0", "s", "create"])),
+ Some(InstallMode::Create)
+ );
+ for invalid in [
+ args(&["r", "p", "0"]),
+ args(&["r", "p", "0", "s", "replace"]),
+ args(&["r", "p", "0", "s", "create", "x"]),
+ ] {
+ assert_eq!(mode(&invalid), None);
+ }
+ let failure = |rejection, kind| write_file::Failure {
+ committed: false,
+ rejection,
+ error: io::Error::from(kind),
+ };
+ for (expected, value) in [
+ (
+ "destination_directory",
+ failure(Some(Rejection::Directory), io::ErrorKind::AlreadyExists),
+ ),
+ (
+ "unsafe_destination",
+ failure(Some(Rejection::Unsafe), io::ErrorKind::AlreadyExists),
+ ),
+ ("invalid_input", failure(None, io::ErrorKind::InvalidInput)),
+ ("write_failed", failure(None, io::ErrorKind::NotFound)),
+ ] {
+ assert_eq!(error_code(&value), expected);
+ }
+ }
+}
diff --git a/packages/codex-executor/src/write_file.rs b/packages/codex-executor/src/write_file.rs
index f650b57a9..5cc681ef6 100644
--- a/packages/codex-executor/src/write_file.rs
+++ b/packages/codex-executor/src/write_file.rs
@@ -1,5 +1,9 @@
use crate::workspace_path::{anchor, directory};
-use rustix::fs::{AtFlags, FileType, Mode, OFlags, fstat, openat, renameat, statat, unlinkat};
+use rustix::fs::{
+ AtFlags, FileType, Mode, OFlags, RenameFlags, fstat, linkat, mkdirat, openat, renameat,
+ renameat_with, statat, unlinkat,
+};
+use rustix::io::Errno;
use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::{self, Read, Write};
@@ -8,9 +12,30 @@ use std::path::Path;
pub(crate) const MAX_BYTES: u64 = 50 * 1024 * 1024;
+/// Selects the destination policy explicitly for each invocation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum InstallMode {
+ /// Trusted initialization (initial Session files and Skills): the parent must
+ /// exist and an existing regular file is replaced by a fresh inode.
+ Replace,
+ /// Public Environment Files.create: missing parents are created without
+ /// following links and an existing destination is never replaced.
+ Create,
+}
+
+/// A known create-mode refusal. Nothing was installed and staging is removed.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Rejection {
+ /// A directory already occupies the destination.
+ Directory,
+ /// The destination exists, or a path component is a symlink or not a directory.
+ Unsafe,
+}
+
#[derive(Debug)]
pub(crate) struct Failure {
pub committed: bool,
+ pub rejection: Option,
pub error: io::Error,
}
@@ -18,17 +43,35 @@ impl From for Failure {
fn from(error: io::Error) -> Self {
Self {
committed: false,
+ rejection: None,
error,
}
}
}
+impl From for Failure {
+ fn from(error: Errno) -> Self {
+ io::Error::from(error).into()
+ }
+}
+
+impl From for Failure {
+ fn from(rejection: Rejection) -> Self {
+ Self {
+ committed: false,
+ rejection: Some(rejection),
+ error: io::ErrorKind::AlreadyExists.into(),
+ }
+ }
+}
+
pub(crate) fn install(
root: &Path,
relative: &str,
size: u64,
mut input: impl Read,
staging_root: &Path,
+ mode: InstallMode,
) -> Result<(), Failure> {
if size > MAX_BYTES
|| staging_root.starts_with(root)
@@ -43,18 +86,31 @@ pub(crate) fn install(
}
let (parent, leaf) = relative.rsplit_once('/').unwrap_or(("", relative));
let root = anchor(root)?;
- let parent = directory(&root, parent)?;
+ let (held, missing) = match mode {
+ InstallMode::Replace => (directory(&root, parent)?, Vec::new()),
+ InstallMode::Create => existing_prefix(&root, parent)?,
+ };
let staging_parent = directory(&anchor(staging_root)?, "")?;
- if fstat(&parent).map_err(io::Error::from)?.st_dev
- != fstat(&staging_parent).map_err(io::Error::from)?.st_dev
- {
+ let device = fstat(&staging_parent)?.st_dev;
+ if fstat(&held)?.st_dev != device {
return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
}
- match statat(&parent, leaf, AtFlags::SYMLINK_NOFOLLOW) {
- Ok(metadata) if FileType::from_raw_mode(metadata.st_mode) == FileType::RegularFile => (),
- Ok(_) => return Err(io::Error::from(io::ErrorKind::InvalidInput).into()),
- Err(rustix::io::Errno::NOENT) => (),
- Err(error) => return Err(io::Error::from(error).into()),
+ if missing.is_empty() {
+ match (mode, statat(&held, leaf, AtFlags::SYMLINK_NOFOLLOW)) {
+ (_, Err(Errno::NOENT)) => (),
+ (InstallMode::Replace, Ok(metadata))
+ if FileType::from_raw_mode(metadata.st_mode) == FileType::RegularFile => {}
+ (InstallMode::Replace, Ok(_)) => {
+ return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
+ }
+ (InstallMode::Create, Ok(metadata))
+ if FileType::from_raw_mode(metadata.st_mode) == FileType::Directory =>
+ {
+ return Err(Rejection::Directory.into());
+ }
+ (InstallMode::Create, Ok(_)) => return Err(Rejection::Unsafe.into()),
+ (_, Err(error)) => return Err(error.into()),
+ }
}
let staging = Staging::new(&staging_parent)?;
let mut file = &staging.file;
@@ -75,20 +131,99 @@ pub(crate) fn install(
return Err(io::Error::from(io::ErrorKind::InvalidData).into());
}
staging.file.sync_all()?;
- staging.persist(&parent, leaf)?;
+ let parent = match mode {
+ InstallMode::Replace => {
+ staging.replace(&held, leaf)?;
+ held
+ }
+ InstallMode::Create => {
+ // Parents are created only after the complete body is verified.
+ let parent = create_missing(held, &missing)?;
+ if fstat(&parent)?.st_dev != device {
+ return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
+ }
+ staging.install_new(&parent, leaf)?;
+ parent
+ }
+ };
File::from(parent).sync_all().map_err(|error| Failure {
committed: true,
+ rejection: None,
error,
})?;
File::from(staging_parent)
.sync_all()
.map_err(|error| Failure {
committed: true,
+ rejection: None,
error,
})?;
Ok(())
}
+const HELD: OFlags = OFlags::PATH
+ .union(OFlags::DIRECTORY)
+ .union(OFlags::NOFOLLOW)
+ .union(OFlags::CLOEXEC);
+
+/// Walks existing components below the held root without following links. It
+/// returns the deepest existing directory and the components still missing. An
+/// existing symlink or non-directory component rejects; nothing is created here.
+fn existing_prefix<'a>(
+ root: &OwnedFd,
+ relative: &'a str,
+) -> Result<(OwnedFd, Vec<&'a str>), Failure> {
+ let mut fd = openat(
+ root,
+ ".",
+ OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
+ Mode::empty(),
+ )?;
+ if relative.is_empty() {
+ return Ok((fd, Vec::new()));
+ }
+ let parts: Vec<_> = relative.split('/').collect();
+ for (index, part) in parts.iter().enumerate() {
+ match openat(&fd, *part, HELD, Mode::empty()) {
+ Ok(child) => fd = child,
+ Err(Errno::NOENT) => return Ok((fd, parts[index..].to_vec())),
+ Err(Errno::NOTDIR | Errno::LOOP) => return Err(Rejection::Unsafe.into()),
+ Err(error) => return Err(error.into()),
+ }
+ }
+ Ok((fd, Vec::new()))
+}
+
+/// Creates each missing component with mode 0700 relative to the held parent,
+/// then reopens it without following links. A component that appears
+/// concurrently is accepted only if it is a real directory.
+fn create_missing(mut fd: OwnedFd, missing: &[&str]) -> Result {
+ for part in missing {
+ match mkdirat(&fd, *part, Mode::from_raw_mode(0o700)) {
+ Ok(()) => File::from(openat(
+ &fd,
+ ".",
+ OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
+ Mode::empty(),
+ )?)
+ .sync_all()?,
+ Err(Errno::EXIST) => (),
+ Err(error) => return Err(error.into()),
+ }
+ fd = match openat(&fd, *part, HELD, Mode::empty()) {
+ Ok(child) => child,
+ Err(Errno::NOTDIR | Errno::LOOP) => return Err(Rejection::Unsafe.into()),
+ Err(error) => return Err(error.into()),
+ };
+ }
+ Ok(openat(
+ &fd,
+ ".",
+ OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
+ Mode::empty(),
+ )?)
+}
+
struct Staging<'a> {
parent: &'a OwnedFd,
name: String,
@@ -113,11 +248,55 @@ impl<'a> Staging<'a> {
})
}
- fn persist(mut self, destination: &OwnedFd, leaf: &str) -> io::Result<()> {
+ fn replace(mut self, destination: &OwnedFd, leaf: &str) -> io::Result<()> {
renameat(self.parent, self.name.as_str(), destination, leaf)?;
self.committed = true;
Ok(())
}
+
+ /// Atomically installs the file only if the destination name is free.
+ fn install_new(self, destination: &OwnedFd, leaf: &str) -> Result<(), Failure> {
+ match renameat_with(
+ self.parent,
+ self.name.as_str(),
+ destination,
+ leaf,
+ RenameFlags::NOREPLACE,
+ ) {
+ Ok(()) => {
+ self.commit();
+ Ok(())
+ }
+ Err(Errno::EXIST) => Err(Rejection::Unsafe.into()),
+ // The filesystem or kernel lacks RENAME_NOREPLACE.
+ Err(Errno::INVAL | Errno::NOSYS) => self.link_new(destination, leaf),
+ Err(error) => Err(error.into()),
+ }
+ }
+
+ /// linkat never replaces an existing name. Removing the staging name after
+ /// the link is best-effort cleanup of an already installed file.
+ fn link_new(self, destination: &OwnedFd, leaf: &str) -> Result<(), Failure> {
+ match linkat(
+ self.parent,
+ self.name.as_str(),
+ destination,
+ leaf,
+ AtFlags::empty(),
+ ) {
+ Ok(()) => {
+ let _ = unlinkat(self.parent, self.name.as_str(), AtFlags::empty());
+ self.commit();
+ Ok(())
+ }
+ Err(Errno::EXIST) => Err(Rejection::Unsafe.into()),
+ Err(error) => Err(error.into()),
+ }
+ }
+
+ fn commit(mut self) {
+ self.committed = true;
+ }
}
impl Drop for Staging<'_> {
diff --git a/packages/codex-executor/src/write_file_tests.rs b/packages/codex-executor/src/write_file_tests.rs
index c14fab048..f0fd1ad21 100644
--- a/packages/codex-executor/src/write_file_tests.rs
+++ b/packages/codex-executor/src/write_file_tests.rs
@@ -16,8 +16,17 @@ fn frame(data: &[u8]) -> impl Read + '_ {
fn install(root: &Path, relative: &str, size: u64, input: impl Read) -> Result<(), Failure> {
let staging = fixture();
- let result = super::install(root, relative, size, input, staging.path());
+ let result = super::install(
+ root,
+ relative,
+ size,
+ input,
+ staging.path(),
+ InstallMode::Replace,
+ );
assert_eq!(fs::read_dir(staging.path()).unwrap().count(), 0);
+ // Replace mode keeps the legacy error codes of trusted initialization.
+ assert!(result.as_ref().err().is_none_or(|f| f.rejection.is_none()));
result
}
@@ -45,9 +54,16 @@ fn requires_existing_disjoint_nofollow_staging_before_consuming_input() {
staging.join("../private"),
Path::new("relative").to_path_buf(),
] {
- let failure = super::install(&root, "file", 3, Unread, &invalid).unwrap_err();
+ for mode in [InstallMode::Replace, InstallMode::Create] {
+ let failure = super::install(&root, "new/file", 3, Unread, &invalid, mode).unwrap_err();
+ assert!(!failure.committed);
+ assert!(failure.rejection.is_none());
+ }
+ let failure =
+ super::install(&root, "file", 3, Unread, &invalid, InstallMode::Replace).unwrap_err();
assert!(!failure.committed);
assert_eq!(fs::read(root.join("file")).unwrap(), b"old");
+ assert!(!root.join("new").exists());
}
assert_eq!(fs::read_dir(&staging).unwrap().count(), 0);
}
@@ -220,3 +236,259 @@ fn requires_matching_commit_trailer_and_does_not_wait_for_eof() {
install(f.path(), "file", 3, frame(b"new").chain(NoMore)).unwrap();
assert_eq!(fs::read(f.path().join("file")).unwrap(), b"new");
}
+
+fn create(root: &Path, relative: &str, size: u64, input: impl Read) -> Result<(), Failure> {
+ let staging = fixture();
+ let result = super::install(
+ root,
+ relative,
+ size,
+ input,
+ staging.path(),
+ InstallMode::Create,
+ );
+ assert_eq!(fs::read_dir(staging.path()).unwrap().count(), 0);
+ result
+}
+
+fn rejection(result: Result<(), Failure>) -> Option {
+ let failure = result.unwrap_err();
+ assert!(!failure.committed);
+ failure.rejection
+}
+
+fn mode(path: &Path) -> u32 {
+ fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777
+}
+
+struct Unread;
+
+impl Read for Unread {
+ fn read(&mut self, _: &mut [u8]) -> io::Result {
+ panic!("a known conflict must be rejected before reading input")
+ }
+}
+
+#[test]
+fn create_mode_makes_missing_parents_after_verified_input() {
+ let f = fixture();
+ let root = f.path().join("workspace");
+ fs::create_dir(&root).unwrap();
+ create(&root, "top.txt", 3, frame(b"top")).unwrap();
+ create(&root, "n1/n2/nested.txt", 6, frame(b"nested")).unwrap();
+ assert_eq!(fs::read(root.join("n1/n2/nested.txt")).unwrap(), b"nested");
+ assert_eq!(fs::read(root.join("top.txt")).unwrap(), b"top");
+ for created in ["n1", "n1/n2"] {
+ assert!(fs::symlink_metadata(root.join(created)).unwrap().is_dir());
+ assert_eq!(mode(&root.join(created)), 0o700);
+ }
+ assert_eq!(mode(&root.join("n1/n2/nested.txt")), 0o600);
+ // An existing prefix is reused; only the missing tail is created.
+ create(&root, "n1/n3/deeper/empty", 0, frame(b"")).unwrap();
+ assert!(
+ fs::read(root.join("n1/n3/deeper/empty"))
+ .unwrap()
+ .is_empty()
+ );
+ // Incomplete or mismatched input creates no parents.
+ assert!(rejection(create(&root, "p1/p2/file", 3, Cursor::new(b"ab"))).is_none());
+ let mismatched = [b"abc".as_slice(), &[0u8; 32]].concat();
+ assert!(rejection(create(&root, "p1/p2/file", 3, Cursor::new(mismatched))).is_none());
+ assert!(!root.join("p1").exists());
+}
+
+#[test]
+fn create_mode_never_replaces_existing_destinations() {
+ let f = fixture();
+ let root = f.path().join("workspace");
+ fs::create_dir_all(root.join("n1/n2")).unwrap();
+ let outside = f.path().join("outside");
+ fs::write(&outside, b"outside").unwrap();
+ fs::hard_link(&outside, root.join("alias")).unwrap();
+ fs::write(root.join("n1/tracked.txt"), b"old").unwrap();
+ fs::write(root.join("empty"), b"").unwrap();
+ rustix::fs::mknodat(
+ rustix::fs::CWD,
+ root.join("fifo").as_path(),
+ FileType::Fifo,
+ rustix::fs::Mode::from_raw_mode(0o600),
+ 0,
+ )
+ .unwrap();
+ for name in ["n1/tracked.txt", "alias", "empty", "fifo"] {
+ assert_eq!(
+ rejection(create(&root, name, 3, Unread)),
+ Some(Rejection::Unsafe),
+ "{name}"
+ );
+ }
+ for name in ["n1", "n1/n2"] {
+ assert_eq!(
+ rejection(create(&root, name, 3, Unread)),
+ Some(Rejection::Directory),
+ "{name}"
+ );
+ assert!(fs::symlink_metadata(root.join(name)).unwrap().is_dir());
+ }
+ assert_eq!(fs::read(root.join("n1/tracked.txt")).unwrap(), b"old");
+ assert_eq!(fs::read(&outside).unwrap(), b"outside");
+ assert_eq!(
+ fs::metadata(&outside).unwrap().ino(),
+ fs::metadata(root.join("alias")).unwrap().ino()
+ );
+ assert!(fs::read(root.join("empty")).unwrap().is_empty());
+ // A second create of the same path is refused after the first commits.
+ create(&root, "n1/once.txt", 3, frame(b"one")).unwrap();
+ assert_eq!(
+ rejection(create(&root, "n1/once.txt", 3, frame(b"two"))),
+ Some(Rejection::Unsafe)
+ );
+ assert_eq!(fs::read(root.join("n1/once.txt")).unwrap(), b"one");
+}
+
+#[test]
+fn create_mode_rejects_symlink_and_nondirectory_components() {
+ let f = fixture();
+ let root = f.path().join("workspace");
+ fs::create_dir_all(root.join("real")).unwrap();
+ let outside = f.path().join("outside");
+ fs::create_dir(&outside).unwrap();
+ fs::write(outside.join("file"), b"canary").unwrap();
+ symlink(outside.join("file"), root.join("leaf-link")).unwrap();
+ symlink(&outside, root.join("dir-link")).unwrap();
+ symlink(&outside, root.join("real/nested-link")).unwrap();
+ symlink(root.join("real"), root.join("inside-link")).unwrap();
+ symlink(f.path().join("missing"), root.join("dangling")).unwrap();
+ fs::write(root.join("plain"), b"plain").unwrap();
+ for name in [
+ "leaf-link",
+ "dir-link",
+ "dangling",
+ "inside-link",
+ "dir-link/file",
+ "dir-link/new/file",
+ "real/nested-link/file",
+ "inside-link/file",
+ "dangling/file",
+ "plain/child",
+ "plain/child/deeper",
+ ] {
+ assert_eq!(
+ rejection(create(&root, name, 3, Unread)),
+ Some(Rejection::Unsafe),
+ "{name}"
+ );
+ }
+ assert_eq!(fs::read(outside.join("file")).unwrap(), b"canary");
+ assert_eq!(fs::read_dir(&outside).unwrap().count(), 1);
+ assert_eq!(fs::read_dir(root.join("real")).unwrap().count(), 1);
+ assert!(!f.path().join("missing").exists());
+ for invalid in [
+ "../outside/file",
+ "/file",
+ "a/../b",
+ "a//b",
+ "a\\b",
+ "x\n",
+ ".",
+ ] {
+ assert!(
+ rejection(create(&root, invalid, 3, Unread)).is_none(),
+ "{invalid:?}"
+ );
+ }
+}
+
+/// Changes the workspace once, after the pre-install checks and before commit.
+struct Race<'a, R> {
+ inner: R,
+ change: Option>,
+}
+
+impl Read for Race<'_, R> {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result {
+ if let Some(change) = self.change.take() {
+ change();
+ }
+ self.inner.read(buf)
+ }
+}
+
+fn racing<'a>(data: &'a [u8], change: impl FnOnce() + 'a) -> impl Read + 'a {
+ Race {
+ inner: frame(data),
+ change: Some(Box::new(change)),
+ }
+}
+
+#[test]
+fn create_mode_race_never_replaces_or_follows() {
+ let f = fixture();
+ let root = f.path().join("workspace");
+ fs::create_dir(&root).unwrap();
+ let outside = f.path().join("outside");
+ fs::create_dir(&outside).unwrap();
+ // The destination appears after the leaf check.
+ let target = root.join("raced.txt");
+ let input = racing(b"api", || fs::write(&target, b"racer").unwrap());
+ assert_eq!(
+ rejection(create(&root, "raced.txt", 3, input)),
+ Some(Rejection::Unsafe)
+ );
+ assert_eq!(fs::read(&target).unwrap(), b"racer");
+ // A directory appears at the destination.
+ let input = racing(b"api", || fs::create_dir(root.join("raced-dir")).unwrap());
+ assert_eq!(
+ rejection(create(&root, "raced-dir", 3, input)),
+ Some(Rejection::Unsafe)
+ );
+ assert_eq!(fs::read_dir(root.join("raced-dir")).unwrap().count(), 0);
+ // A missing parent becomes a symlink before it is created.
+ let link = root.join("parent");
+ let input = racing(b"api", || symlink(&outside, &link).unwrap());
+ assert_eq!(
+ rejection(create(&root, "parent/child/file", 3, input)),
+ Some(Rejection::Unsafe)
+ );
+ assert_eq!(fs::read_dir(&outside).unwrap().count(), 0);
+ // A missing parent is created concurrently as a real directory.
+ let concurrent = root.join("shared");
+ let input = racing(b"api", || fs::create_dir(&concurrent).unwrap());
+ create(&root, "shared/file", 3, input).unwrap();
+ assert_eq!(fs::read(root.join("shared/file")).unwrap(), b"api");
+ // A held existing ancestor that is replaced by a link keeps the commit inside.
+ fs::create_dir(root.join("a")).unwrap();
+ let input = racing(b"api", || {
+ fs::rename(root.join("a"), root.join("held")).unwrap();
+ symlink(&outside, root.join("a")).unwrap();
+ });
+ create(&root, "a/b/file", 3, input).unwrap();
+ assert_eq!(fs::read(root.join("held/b/file")).unwrap(), b"api");
+ assert_eq!(fs::read_dir(&outside).unwrap().count(), 0);
+}
+
+#[test]
+fn link_fallback_never_replaces_and_removes_staging() {
+ let f = fixture();
+ let staging_root = f.path().join("staging");
+ let root = f.path().join("workspace");
+ fs::create_dir(&staging_root).unwrap();
+ fs::create_dir(&root).unwrap();
+ fs::write(root.join("existing"), b"old").unwrap();
+ let staging_parent = directory(&anchor(&staging_root).unwrap(), "").unwrap();
+ let parent = directory(&anchor(&root).unwrap(), "").unwrap();
+ let staging = Staging::new(&staging_parent).unwrap();
+ (&staging.file).write_all(b"new").unwrap();
+ assert_eq!(
+ staging.link_new(&parent, "existing").unwrap_err().rejection,
+ Some(Rejection::Unsafe)
+ );
+ assert_eq!(fs::read(root.join("existing")).unwrap(), b"old");
+ assert_eq!(fs::read_dir(&staging_root).unwrap().count(), 0);
+ let staging = Staging::new(&staging_parent).unwrap();
+ (&staging.file).write_all(b"new").unwrap();
+ staging.link_new(&parent, "fresh").unwrap();
+ assert_eq!(fs::read(root.join("fresh")).unwrap(), b"new");
+ assert_eq!(fs::metadata(root.join("fresh")).unwrap().nlink(), 1);
+ assert_eq!(fs::read_dir(&staging_root).unwrap().count(), 0);
+}
diff --git a/services/agents-api/internal/api/environment_files_create.go b/services/agents-api/internal/api/environment_files_create.go
index 8b57122b9..9184b211a 100644
--- a/services/agents-api/internal/api/environment_files_create.go
+++ b/services/agents-api/internal/api/environment_files_create.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
+ "errors"
"io"
"net/http"
"slices"
@@ -29,7 +30,7 @@ func WithEnvironmentFileWriter(writer EnvironmentFileWriter) Option {
}
// @Summary Create an Environment file from inline bytes or a source file
-// @Description Uploads standard Base64 bytes to a file beneath /workspace in a qualified local Environment and returns 201. Accepts inline bytes or a project-owned source file_id through the same write path. Unknown body fields are rejected with their name as param. Basic public hosted creation requires explicit managed Runtime configuration; an openai_hosted Environment that has not connected yet returns 400. A private 50 MiB decoded-content limit applies. The parent directory must exist. Replacement installs a new mode-0600 inode; upstream overwrite metadata semantics remain unverified. Idle writes exclude execution. Missing receipts return unavailable and retain a durable mutation gate without automatic replay. Error/timing parity with upstream remains unverified.
+// @Description Uploads standard Base64 bytes to a file beneath /workspace in a qualified local Environment and returns 201. Accepts inline bytes or a project-owned source file_id through the same write path. Unknown body fields are rejected with their name as param. Basic public hosted creation requires explicit managed Runtime configuration; an openai_hosted Environment that has not connected yet returns 400. Inline data is limited to 5 MiB decoded and a file_id copy to 50 MiB. Missing parent directories are created with mode 0700 and the file with mode 0600. An existing destination is never replaced; a directory, an existing file or a path through a symlink or non-directory returns 400. Idle writes exclude execution. Missing receipts return unavailable and retain a durable mutation gate without automatic replay. Error/timing parity with upstream remains unverified.
// @Tags Environments
// @Accept json
// @Produce json
@@ -97,8 +98,8 @@ func (h *Handler) createEnvironmentFile(w http.ResponseWriter, r *http.Request)
writeStoreError(w, r, store.ErrInvalidInput)
return
}
- if len(data) > proto.WorkspaceWriteMaxBytes {
- writeStoreError(w, r, store.ErrSourceFileTooLarge)
+ if len(data) > maxInlineEnvironmentFileBytes {
+ writeFieldError(w, errEnvironmentFileInlineTooLarge)
return
}
}
@@ -136,7 +137,9 @@ func (h *Handler) createEnvironmentFile(w http.ResponseWriter, r *http.Request)
}
size, err := h.fileWriter.WriteEnvironmentFile(r.Context(), environment, strings.TrimPrefix(*request.Path, "/workspace/"), data)
if err != nil {
- writeStoreError(w, r, err)
+ if !writeFieldError(w, environmentFileWriteError(err)) {
+ writeStoreError(w, r, err)
+ }
return
}
if size != int64(len(data)) {
@@ -152,6 +155,30 @@ var (
errEnvironmentFileCreateComponents = &fieldError{message: "environment.files[0].path cannot contain empty, . or .. path components"}
)
+// maxInlineEnvironmentFileBytes is the official decoded inline bound (HE-15),
+// measured before any Runtime work. file_id copies keep the 50 MiB destination bound.
+const maxInlineEnvironmentFileBytes = 5 << 20
+
+// Official Files.create size and destination errors (HE-12, HE-13, HE-15).
+var (
+ errEnvironmentFileInlineTooLarge = &fieldError{message: "environment.files[0].data exceeds the 5 MiB decoded limit"}
+ errEnvironmentFileConflict = &fieldError{message: "file path conflicts with an existing environment file"}
+ errEnvironmentFileUnsafe = &fieldError{message: "environment.files paths must not traverse symlinks or overwrite existing files"}
+)
+
+// environmentFileWriteError maps the installer's known destination refusals.
+// Core keeps no path ledger of earlier writes, so an existing regular file uses
+// the untracked-file message even when an earlier Files.create wrote it.
+func environmentFileWriteError(err error) error {
+ switch {
+ case errors.Is(err, execution.ErrEnvironmentFileDirectory):
+ return errEnvironmentFileConflict
+ case errors.Is(err, execution.ErrEnvironmentFileUnsafe):
+ return errEnvironmentFileUnsafe
+ }
+ return nil
+}
+
// environmentFileCreatePathError accepts exactly the canonical absolute paths
// below /workspace; the accepted set is unchanged, only the errors are specific.
func environmentFileCreatePathError(value string) error {
diff --git a/services/agents-api/internal/api/environment_files_write_test.go b/services/agents-api/internal/api/environment_files_write_test.go
new file mode 100644
index 000000000..db9437177
--- /dev/null
+++ b/services/agents-api/internal/api/environment_files_write_test.go
@@ -0,0 +1,79 @@
+package api
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
+ "github.com/google/uuid"
+)
+
+// Files.create write semantics rows FW2–FW6 of the workspace-file-writes batch.
+
+func inlineCreateBody(size int) string {
+ body, _ := json.Marshal(map[string]any{"type": "inline", "path": "/workspace/n1/n2/big.bin", "data": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, size))})
+ return string(body)
+}
+
+func TestEnvironmentFileCreateInlineDecodedLimit(t *testing.T) {
+ const message = "environment.files[0].data exceeds the 5 MiB decoded limit"
+ h, f := environmentFileCreateHandler(t)
+ if w := requestCreateEnvironmentFile(h, f.environment.ID, inlineCreateBody(5<<20), "files-key"); w.Code != 201 || f.writes != 1 || len(f.data) != 5<<20 || f.path != "n1/n2/big.bin" {
+ t.Fatal("exact 5 MiB inline rejected", w.Code, w.Body)
+ }
+ for _, size := range []int{5<<20 + 1, 50 << 20} {
+ assertListQueryError(t, requestCreateEnvironmentFile(h, f.environment.ID, inlineCreateBody(size), "files-key"), "invalid_request_error", nil, message)
+ }
+ // The bound is checked before provisioning or any Runtime work.
+ f.environment.Configuration, f.environment.Status = json.RawMessage(`{"type":"openai_hosted","network":{"access":"disabled"}}`), "pending"
+ assertListQueryError(t, requestCreateEnvironmentFile(h, f.environment.ID, inlineCreateBody(5<<20+1), "files-key"), "invalid_request_error", nil, message)
+ if f.writes != 1 {
+ t.Fatal("oversized inline data reached the writer", f.writes)
+ }
+ // Tenant isolation precedes the body.
+ foreign := requestCreateEnvironmentFile(h, f.environment.ID, inlineCreateBody(5<<20+1), "other-key")
+ missing := requestCreateEnvironmentFile(h, uuid.NewString(), inlineCreateBody(5<<20+1), "files-key")
+ if foreign.Code != 404 || foreign.Body.String() != missing.Body.String() {
+ t.Fatal("foreign oversized create inspected", foreign.Code, foreign.Body)
+ }
+}
+
+func TestEnvironmentFileCreateSourceCopyKeepsDestinationBound(t *testing.T) {
+ sources := &sourceFilesFixture{}
+ h, f := environmentFileCreateHandler(t, WithSourceFiles(sources))
+ data := bytes.Repeat([]byte{9}, 6<<20)
+ sources.tenant, sources.data = f.environment.TenantID, data
+ sources.file = store.SourceFile{ID: "file-" + uuid.NewString(), SizeBytes: int64(len(data)), CreatedAt: time.Unix(1, 0)}
+ body := `{"type":"file_id","file_id":"` + sources.file.ID + `","path":"/workspace/copy.bin"}`
+ if w := requestCreateEnvironmentFile(h, f.environment.ID, body, "files-key"); w.Code != 201 || f.writes != 1 || !bytes.Equal(f.data, data) {
+ t.Fatal("6 MiB source copy rejected", w.Code, w.Body)
+ }
+}
+
+func TestEnvironmentFileCreateDestinationConflicts(t *testing.T) {
+ const conflict = "file path conflicts with an existing environment file"
+ const unsafe = "environment.files paths must not traverse symlinks or overwrite existing files"
+ h, f := environmentFileCreateHandler(t)
+ body := `{"type":"inline","data":"YWJj","path":"/workspace/n1"}`
+ for _, tc := range []struct {
+ err error
+ code any
+ message string
+ }{
+ {execution.ErrEnvironmentFileDirectory, "invalid_request_error", conflict},
+ {execution.ErrEnvironmentFileUnsafe, "invalid_request_error", unsafe},
+ // A generic installer rejection keeps the local code.
+ {store.ErrInvalidInput, "invalid_request", "Invalid resource identifier or request limits."},
+ } {
+ f.err = tc.err
+ assertListQueryError(t, requestCreateEnvironmentFile(h, f.environment.ID, body, "files-key"), tc.code, nil, tc.message)
+ }
+ f.err = execution.ErrExecutionUnavailable
+ if w := requestCreateEnvironmentFile(h, f.environment.ID, body, "files-key"); w.Code != 503 {
+ t.Fatal("unknown outcome changed", w.Code, w.Body)
+ }
+}
diff --git a/services/agents-api/internal/execution/environment_file_write.go b/services/agents-api/internal/execution/environment_file_write.go
index c28e58ce7..78df59e15 100644
--- a/services/agents-api/internal/execution/environment_file_write.go
+++ b/services/agents-api/internal/execution/environment_file_write.go
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "fmt"
"time"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
@@ -12,6 +13,13 @@ import (
"github.com/google/uuid"
)
+// Known Files.create destination refusals reported by the Runtime installer.
+// They wrap store.ErrInvalidInput; the rejected write installed nothing.
+var (
+ ErrEnvironmentFileDirectory = fmt.Errorf("%w: environment file destination is a directory", store.ErrInvalidInput)
+ ErrEnvironmentFileUnsafe = fmt.Errorf("%w: environment file destination exists or traverses a link", store.ErrInvalidInput)
+)
+
type fileWriteResult struct {
size int64
err error
@@ -117,7 +125,12 @@ func (w *Worker) runFileWrite(owner context.Context, request fileWriteRequest) f
return unavailable
}
if state == "rejected" {
- if result.ErrorCode == "invalid_request" || result.ErrorCode == "write_rejected" {
+ switch {
+ case result.ErrorCode == "write_rejected" && result.Reason == proto.WorkspaceWriteReasonDirectory:
+ return fileWriteResult{err: ErrEnvironmentFileDirectory}
+ case result.ErrorCode == "write_rejected" && result.Reason == proto.WorkspaceWriteReasonUnsafe:
+ return fileWriteResult{err: ErrEnvironmentFileUnsafe}
+ case result.ErrorCode == "invalid_request" || result.ErrorCode == "write_rejected":
return fileWriteResult{err: store.ErrInvalidInput}
}
return unavailable
diff --git a/services/agents-api/internal/store/environment_file_write_semantics_public_test.go b/services/agents-api/internal/store/environment_file_write_semantics_public_test.go
new file mode 100644
index 000000000..4c51697dd
--- /dev/null
+++ b/services/agents-api/internal/store/environment_file_write_semantics_public_test.go
@@ -0,0 +1,177 @@
+package store_test
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
+ "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
+ "github.com/google/uuid"
+)
+
+type fileCreateResponse struct {
+ status int
+ body string
+}
+
+// serveFileWrite answers one complete daemon transfer with the final result.
+func serveFileWrite(h *dispatchHarness, final proto.WorkspaceWriteResultPayload) string {
+ h.t.Helper()
+ begin := h.read(proto.TypeWorkspaceWrite)
+ var request proto.WorkspaceWritePayload
+ if begin.DecodePayload(&request) != nil || request.Step != "begin" {
+ h.t.Fatal("write did not begin")
+ }
+ h.write(begin.ID, proto.TypeWorkspaceWriteResult, proto.WorkspaceWriteResultPayload{Outcome: "ready"})
+ for offset := 0; offset < request.SizeBytes; {
+ chunk := h.read(proto.TypeWorkspaceWrite)
+ var body proto.WorkspaceWritePayload
+ if chunk.ID != begin.ID || chunk.DecodePayload(&body) != nil || body.Step != "chunk" {
+ h.t.Fatal("chunk changed")
+ }
+ offset += len(body.Data)
+ h.write(begin.ID, proto.TypeWorkspaceWriteResult, proto.WorkspaceWriteResultPayload{Outcome: "received", Offset: offset})
+ }
+ if commit := h.read(proto.TypeWorkspaceWrite); commit.ID != begin.ID {
+ h.t.Fatal("commit changed")
+ }
+ h.write(begin.ID, proto.TypeWorkspaceWriteResult, final)
+ return begin.ID
+}
+
+func TestEnvironmentFileCreateRejectionsLeaveNoReceiptOrConsumption(t *testing.T) {
+ h, w, environment := localWorker(t, true, false)
+ _, pool := store.NewTestStore(t)
+ if _, err := pool.Exec(t.Context(), `UPDATE environments SET status='connected' WHERE id=$1`, environment.ID); err != nil {
+ t.Fatal(err)
+ }
+ token, other := uuid.NewString(), uuid.NewString()
+ auth, err := api.NewAuthenticator([]api.APIKey{
+ {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: h.tenant},
+ {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "tenant-b", TokenSHA256: device.HashCredential(other), TenantID: uuid.NewString()},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ handler, err := api.NewHandler(h.s, auth, "codex", api.WithEnvironmentFileWriter(w), api.WithSourceFiles(h.s))
+ if err != nil {
+ t.Fatal(err)
+ }
+ server := httptest.NewServer(handler)
+ defer server.Close()
+ source, err := h.s.CreateSourceFile(t.Context(), h.tenant, func(out io.Writer) (store.SourceFileUpload, error) {
+ _, err := out.Write([]byte("src"))
+ return store.SourceFileUpload{Filename: "source.txt", Purpose: "user_data"}, err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ post := func(key, id, body string) <-chan fileCreateResponse {
+ done := make(chan fileCreateResponse, 1)
+ go func() {
+ r, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/agents/environments/"+id+"/files", strings.NewReader(body))
+ r.Header.Set("Authorization", "Bearer "+key)
+ r.Header.Set("OpenAI-Beta", "agents=v1")
+ resp, err := server.Client().Do(r)
+ if err != nil {
+ done <- fileCreateResponse{}
+ return
+ }
+ defer resp.Body.Close()
+ data, _ := io.ReadAll(resp.Body)
+ done <- fileCreateResponse{resp.StatusCode, string(data)}
+ }()
+ return done
+ }
+ await := func(done <-chan fileCreateResponse) fileCreateResponse {
+ select {
+ case got := <-done:
+ return got
+ case <-time.After(10 * time.Second):
+ t.Fatal("create did not return")
+ return fileCreateResponse{}
+ }
+ }
+ states := func() map[string]int {
+ rows, err := pool.Query(t.Context(), `SELECT state, count(*) FROM environment_file_writes WHERE environment_id=$1 GROUP BY state`, environment.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ result := map[string]int{}
+ for rows.Next() {
+ var state string
+ var count int
+ if err := rows.Scan(&state, &count); err != nil {
+ t.Fatal(err)
+ }
+ result[state] = count
+ }
+ return result
+ }
+ assertError := func(got fileCreateResponse, message string) {
+ t.Helper()
+ var body map[string]any
+ want := map[string]any{"error": map[string]any{"type": "invalid_request_error", "code": "invalid_request_error", "param": nil, "message": message}}
+ if got.status != 400 || json.Unmarshal([]byte(got.body), &body) != nil || !reflect.DeepEqual(body, want) {
+ t.Fatalf("status=%d body=%s want=%v", got.status, got.body, want)
+ }
+ }
+ inline := `{"type":"inline","data":"YWJj","path":"/workspace/n1/existing.txt"}`
+ copyBody := `{"type":"file_id","file_id":"` + source.ID + `","path":"/workspace/link/copy.txt"}`
+ rejected := func(reason string) proto.WorkspaceWriteResultPayload {
+ return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: "write_rejected", Reason: reason}
+ }
+ for _, tc := range []struct {
+ body, reason, message string
+ }{
+ {`{"type":"inline","data":"YWJj","path":"/workspace/n1"}`, proto.WorkspaceWriteReasonDirectory, "file path conflicts with an existing environment file"},
+ {inline, proto.WorkspaceWriteReasonUnsafe, "environment.files paths must not traverse symlinks or overwrite existing files"},
+ {copyBody, proto.WorkspaceWriteReasonUnsafe, "environment.files paths must not traverse symlinks or overwrite existing files"},
+ } {
+ done := post(token, environment.ID, tc.body)
+ id := serveFileWrite(h, rejected(tc.reason))
+ assertError(await(done), tc.message)
+ if intent, err := h.s.GetEnvironmentFileWrite(t.Context(), h.tenant, environment.ID, id); err != nil || intent.State != "rejected" {
+ t.Fatal("rejection did not settle", intent, err)
+ }
+ }
+ // The rejected copy did not consume its Source File.
+ if got, err := h.s.GetSourceFile(t.Context(), h.tenant, source.ID); err != nil || got.SizeBytes != 3 {
+ t.Fatal("source file consumed", got, err)
+ }
+ // The inline bound is checked before any mutation intent or dispatch.
+ oversized, _ := json.Marshal(map[string]string{"type": "inline", "path": "/workspace/big.bin", "data": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 5<<20+1))})
+ assertError(await(post(token, environment.ID, string(oversized))), "environment.files[0].data exceeds the 5 MiB decoded limit")
+ // Tenant B sees the Environment as missing, before body inspection.
+ foreign, missing := await(post(other, environment.ID, inline)), await(post(token, uuid.NewString(), inline))
+ if foreign.status != 404 || foreign != missing {
+ t.Fatal("tenant B reached the Environment", foreign, missing)
+ }
+ if got := states(); !reflect.DeepEqual(got, map[string]int{"rejected": 3}) {
+ t.Fatal("rejections left a receipt or blocking intent", got)
+ }
+ // Known rejections release the mutation owner for a successor.
+ done := post(token, environment.ID, copyBody)
+ serveFileWrite(h, proto.WorkspaceWriteResultPayload{Outcome: "completed", SizeBytes: 3})
+ if got := await(done); got.status != 201 {
+ t.Fatal("successor rejected", got)
+ }
+ if got := states(); !reflect.DeepEqual(got, map[string]int{"rejected": 3, "committed": 1}) {
+ t.Fatal("successor receipt", got)
+ }
+ session, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID)
+ if err != nil || session.LastTurn != nil {
+ t.Fatal("file writes created model execution", err)
+ }
+}
diff --git a/services/agents-api/tests/official_environment_files.py b/services/agents-api/tests/official_environment_files.py
index ad52a6f88..af51892f3 100644
--- a/services/agents-api/tests/official_environment_files.py
+++ b/services/agents-api/tests/official_environment_files.py
@@ -1,5 +1,6 @@
"""Pinned Files.list checks for a known, unchanged directory of regular files."""
+import base64
from pathlib import PurePosixPath
from urllib.parse import urlencode
@@ -202,6 +203,24 @@ def verify_file_create_rows(client, http, environment_id, directory):
return path
+def verify_file_write_rows(client, http, environment_id, directory):
+ """Files.create write rows FW1-FW6: parent creation, no replacement and the inline bound."""
+ endpoint = str(client.base_url).rstrip("/") + "/agents/environments/" + environment_id + "/files"
+ headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"}
+ path = directory + "/n1/n2/nested.txt"
+ response = http.post(endpoint, headers=headers, json={"type": "inline", "data": "bmVzdGVk", "path": path})
+ assert response.status_code == 201, "Files.create did not create missing parents"
+ assert response.json() == {"environment_id": environment_id, "object": "agent.environment.file", "path": path, "size_bytes": 6}
+ response = http.post(endpoint, headers=headers, json={"type": "inline", "data": "b3RoZXI=", "path": path})
+ assert_invalid_request(response, "environment.files paths must not traverse symlinks or overwrite existing files")
+ response = http.post(endpoint, headers=headers, json={"type": "inline", "data": "cg==", "path": directory + "/n1"})
+ assert_invalid_request(response, "file path conflicts with an existing environment file")
+ oversized = base64.b64encode(b"x" * ((5 << 20) + 1)).decode()
+ response = http.post(endpoint, headers=headers, json={"type": "inline", "data": oversized, "path": directory + "/oversized.bin"})
+ assert_invalid_request(response, "environment.files[0].data exceeds the 5 MiB decoded limit")
+ return path
+
+
def verify_files_provisioning(client, http, environment_id):
"""Files.list/create on a pending hosted Environment (F9). Returns None when it connected first."""
endpoint = str(client.base_url).rstrip("/") + "/agents/environments/" + environment_id + "/files"
diff --git a/services/agents-api/tests/official_environment_files_create.py b/services/agents-api/tests/official_environment_files_create.py
index 93b20f6b8..d551c7e25 100644
--- a/services/agents-api/tests/official_environment_files_create.py
+++ b/services/agents-api/tests/official_environment_files_create.py
@@ -16,7 +16,7 @@
import httpx2
from openai import OpenAI
from official_environment_files import (verify_environment_files, verify_file_create_rows, verify_file_list_rows,
- verify_file_tenant_isolation)
+ verify_file_tenant_isolation, verify_file_write_rows)
from official_environment_files_native import caller_token
from official_source_files import verify_source_files
@@ -37,9 +37,11 @@ def main():
"empty.bin": b"",
"binary.bin": bytes(range(256)),
"chunked.bin": bytes(range(256)) * 8193,
- "large.bin": b"x" * (50 << 20),
+ # Inline data is bounded at 5 MiB decoded; source copies keep 50 MiB.
+ "large.bin": b"x" * (5 << 20),
"model-input.txt": settings["model_input"].encode(),
}
+ source_cases = {**cases, "large.bin": b"x" * (50 << 20)}
expected, receipts = {}, []
with httpx2.Client(trust_env=False, timeout=215) as http:
client = OpenAI(api_key=token, base_url=base, max_retries=0, _strict_response_validation=True, http_client=http)
@@ -57,7 +59,7 @@ def main():
assert receipt == {"environment_id": environment, "object": "agent.environment.file", "path": path, "size_bytes": len(content)}, "Wrong upload metadata"
expected[path] = len(content)
receipts.append({"path": path, "size": len(content), "sha256": hashlib.sha256(content).hexdigest()})
- source_expected, source_receipts, source_proof = verify_source_files(client, foreign, http, environment, directory, cases)
+ source_expected, source_receipts, source_proof = verify_source_files(client, foreign, http, environment, directory, source_cases)
expected.update(source_expected)
receipts.extend(source_receipts)
pages, continuation = verify_environment_files(client, http, environment, directory, expected)
@@ -69,6 +71,8 @@ def main():
"symlink": "/workspace/stage-link"})
created = verify_file_create_rows(client, http, environment, directory)
receipts.append({"path": created, "size": 3, "sha256": hashlib.sha256(b"201").hexdigest()})
+ nested = verify_file_write_rows(client, http, environment, directory)
+ receipts.append({"path": nested, "size": 6, "sha256": hashlib.sha256(b"nested").hexdigest()})
for body in (
{"type": "inline", "data": "?", "path": target},
{"type": "inline", "data": "", "path": "/workspace/../escape"},
@@ -84,8 +88,8 @@ def main():
assert response.status_code == 404, "Foreign tenant upload accepted"
assert token not in response.text and foreign_token not in response.text, "Credential leaked in error"
print(json.dumps({"sdk": pin["sdk_version"], "commit": pin["commit"], "uploads": receipts, "listing": pages, "sources": source_proof,
- "wire_rows": wire_rows + ["create_201_and_field_errors"],
- "limits": ["Private Environment setup", "Other source purposes/expiration/listing unimplemented", "Overwrite metadata/error parity unverified", "Real-model consumption verified by invoking fixture"]}))
+ "wire_rows": wire_rows + ["create_201_and_field_errors", "create_parents_no_replace_inline_bound"],
+ "limits": ["Private Environment setup", "Other source purposes/expiration/listing unimplemented", "Tracked and untracked existing files share the untracked message", "Real-model consumption verified by invoking fixture"]}))
if __name__ == "__main__":