Skip to content

Align Environment Files.create write semantics - #76

Merged
SaladDay merged 6 commits into
mainfrom
codex/workspace-file-writes
Sep 23, 2026
Merged

SaladDay merged 6 commits into
mainfrom
codex/workspace-file-writes

Conversation

@SaladDay

@SaladDay SaladDay commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Environment Files.create now follows the observed official write semantics. It creates missing parent directories, never overwrites an existing path, and bounds inline data at 5 MiB decoded. Symlink safety is unchanged, and initial Session files, Skills, Plugins and cold resume keep their install behavior. The pinned baseline is unchanged (SDK 3.13.0 / d7c41ef / agents=v1).

Behavior

  • Missing parents are created one component at a time with mkdirat, mode 0700, under the held no-follow directory walk. This happens only after the body digest is verified. An existing symlink or non-directory component still rejects.
  • No replacement:
    • An existing file, FIFO, hard link, symlink leaf or symlink parent returns 400 invalid_request_error, param null, with "environment.files paths must not traverse symlinks or overwrite existing files".
    • A directory destination returns "file path conflicts with an existing environment file".
    • The final install uses renameat2(RENAME_NOREPLACE), with a linkat fallback. Neither can replace a destination, even under races.
    • A rejected write leaves no committed receipt, changes no bytes and consumes no Source File.
  • Inline bound: inline data above 5 MiB decoded returns 400 with "environment.files[0].data exceeds the 5 MiB decoded limit". This is checked before the provisioning check, the Source File lookup and any Worker work. file_id copies keep the 50 MiB bound.
  • Scope of the new mode:
    • Only Files.create uses it, through an explicit create argument to the write helper, used by the daemon local workspace writer for both Core-managed Docker and self_hosted Runtimes.
    • Initial Session files and Skills keep the 4-argument replace mode. Plugins use a separate installer. Cold resume does not reinstall files.
  • Mixed versions fail safe:
    • A new daemon with an old helper rejects writes with invalid_input.
    • An old daemon keeps replace semantics until its image is rebuilt.
    • An old Core ignores the new reason field.
  • Client and Core Web: the 5 MiB inline bound is applied. A Base64-check stack overflow at about 4 MiB is fixed, so exact 5 MiB uploads work.
  • Documented differences:
    • An overwrite of a file written by an earlier Files.create gets the "must not traverse symlinks or overwrite" message, not the official tracked "file path conflicts…" message. Core's write receipts store only a digest, so it can't tell reliably and cheaply.
    • A normal rejection leaves nothing behind. Only a race, an I/O error or a device mismatch after directory creation can leave empty 0700 parent directories.

Evidence

  • HE-10..16 (campaign scan 2), with the official limit table from the Environments Files guide.
  • Recorded in environment-files.md, operation-evidence.md (register FW, row 27), official-semantics-alignment.md and environment-templates.md.

Validation

  • Live acceptance on Core-managed Docker openai_hosted with Codex. Each phase's Core, daemon and helpers were built from the same tree and checked by sha256 inside the Runtime container. No model Turns.

    Case main candidate
    Nested path with missing parents 400 generic 201, listed
    Repeat of an earlier Files.create path 201, overwritten 400, bytes unchanged
    Directory destination 201 (created a file) / 400 generic 400 "file path conflicts…"
    File created by setup_commands 201, overwritten 400, sha256 unchanged
    Symlink parent to /tmp 400 generic 400 official message, /tmp/x.txt absent
    Inline 5 MiB + 1 / exactly 5 MiB 201 / 201 (over-limit file written) 400 official message / 201
    file_id copy of about 6 MiB 201 201, sha256 matches
    Tenant B 404 404

    RENAME_NOREPLACE is supported on the Runtime volume filesystem; a probe returned EEXIST. Cleanup and secret scans passed.

  • Tests:

    • Rust installer tests: parents, conflicts, directory destination, symlink parent and leaf, FIFO, hard link, races and the fallback. Also fmt and clippy.
    • Daemon localworkspace tests, including the native-helper test run with the built helper; this test is opt-in outside this batch.
    • Gateway, Worker and API tests.
    • Real-PostgreSQL tests: no receipt on rejection, tenant B.
    • TS client and Core Web tests.
  • Server gate: all make check targets plus Web typecheck, core-doctor, unit tests and build pass. The Playwright browser cases were not run on the server (no Google Chrome; the user approved the skip). make openapi is byte-identical.

  • Independent blind review by a fresh Claude Code subagent (the user-approved replacement for GPT-6 Astra). It found no blockers and confirmed:

    • no-follow creation;
    • no replacement under races;
    • unchanged replace-mode callers;
    • fail-safe mixed versions;
    • consistent error mapping.

    Its documentation follow-ups are applied in the last commit, verified by focused tests and the gate without a new review, per the user's rule.

Runtime images (Codex, Claude and MiniMax Docker images, the E2B template and user-managed bundles) must be rebuilt for the new write semantics. No full protocol compatibility is claimed.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

The shared workspace installer gains an explicit per-call mode. The new
create mode walks existing components with the held O_PATH|O_NOFOLLOW walk,
creates missing parents with mkdirat (mode 0700) only after the complete body
is verified, reopens each one without following links, and installs with
renameat2(RENAME_NOREPLACE), falling back to linkat plus unlinking the staging
name. An existing file, a directory, a symlink or a non-directory component
rejects with a fixed code and nothing is installed.

The four-argument form keeps the replace mode used by initial Session files
and Skills, including Core-issued installs on older Runtime images.
The local workspace writer always selects the helper's create mode. Its new
directory and unsafe-destination codes become known rejections that the
daemon sends as write_rejected with an optional reason; the gateway accepts a
reason only on that rejection, and older peers ignore it.
Inline data above 5 MiB decoded returns the official 400 before any Runtime
work; file_id copies keep the 50 MiB destination bound. The Worker maps the
installer's destination reasons to the official conflict and symlink or
overwrite messages. Core keeps no path ledger, so an existing file written by
an earlier Files.create uses the untracked message. Rejections settle their
durable intent as rejected and consume no Source File.
The client's strict Base64 check now scans with a flat character class, since
the grouped pattern overflowed the regular-expression stack near 4 MiB and
blocked an exact 5 MiB upload.
Record rows FW1-FW11 in the Environment Files contract with the official
evidence, the installer modes and the tracked/untracked message difference,
register the FW evidence code and update row 27, the coverage summary, the
executor README and the contributor rules.
The daemon's native-helper case runs only when a built helper is supplied.
A normal rejection leaves nothing; only a race, an I/O error or a device
mismatch after parent creation can leave empty mode-0700 directories.
@SaladDay
SaladDay merged commit b6c3f7d into main Sep 23, 2026
3 of 4 checks passed
@SaladDay
SaladDay deleted the codex/workspace-file-writes branch October 7, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant