Skip to content

fix(agent-server): make file upload overwrites atomic - #5902

Open
enyst wants to merge 3 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-upload-atomic
Open

enyst wants to merge 3 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-upload-atomic

Conversation

@enyst

@enyst enyst commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

HUMAN:
This PR proposes a fix for a bug uncovered during the first full agentic run of verify-agent-server.


AGENT:

Summary

 shared _upload_file (workspace + conversation routes)
- open destination with "wb" → stream directly into it
+ resolve symlink target
+ stream into sibling .<name>.<random>.tmp
+ preserve prior mode / owner where allowed
+ os.replace(temp, destination) only after the stream completes

+ handled failure → remove temp; previous destination survives

Read-only targets remain refused; new files retain umask-based permissions.

Why

A failed upload overwrite should leave the previous complete file available.

Issue Number

Fixes #5889

How to Test

Previously recorded verification:

Reproduction commands, results, and limits
  • uv run pytest tests/agent_server/test_file_router.py: 94 passed, 2 skipped (the read-only test skips as root; run as an unprivileged user, all 12 upload tests pass). test_failed_overwrite_keeps_previous_file fails on main.
  • Map proof, AGENT_SERVER_VERIFY_HOME private to this run:
    • main (6e7a853): control-agent-server map run --file F14 --only F14.upload-atomic --fresh --repeat 2 --checkout <main checkout> -> xfail in both attempts.
    • this branch, marker still in place: same command without --checkout -> xpass in both attempts.
    • marker removed: control-agent-server map check ok; map run --file F14 --only F14.upload-atomic --fresh -> pass (re-run after the final code changes).
  • uv run pre-commit run --files <changed files>: all hooks pass.

Video/Screenshots

No UI change; reproduction evidence is above.

Type

  • Bug fix

Notes

Rename replacement creates a new inode: other hard links retain the old content, and the destination directory must be writable. There is no fsync power-loss guarantee; an abrupt kill can leave a temporary file. Upload-onto-directory error mapping is unchanged. #5856 separately adds the returned upload size.

Visual explanation by Codex using HumanLayer show-me.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 python-node-runtime Link
python-minimal amd64, arm64 python-node-runtime Link
python amd64, arm64 python-node-runtime Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:7a865b1-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-7a865b1-python \
  ghcr.io/openhands/agent-server:7a865b1-python

All tags pushed for this build

ghcr.io/openhands/agent-server:7a865b1-golang-amd64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-golang-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-golang-amd64
ghcr.io/openhands/agent-server:7a865b1-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:7a865b1-golang-arm64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-golang-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-golang-arm64
ghcr.io/openhands/agent-server:7a865b1-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:7a865b1-java-amd64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-java-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-java-amd64
ghcr.io/openhands/agent-server:7a865b1-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:7a865b1-java-arm64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-java-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-java-arm64
ghcr.io/openhands/agent-server:7a865b1-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:7a865b1-python-amd64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-python-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-python-amd64
ghcr.io/openhands/agent-server:7a865b1-python-node-runtime-amd64
ghcr.io/openhands/agent-server:7a865b1-python-arm64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-python-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-python-arm64
ghcr.io/openhands/agent-server:7a865b1-python-node-runtime-arm64
ghcr.io/openhands/agent-server:7a865b1-python-minimal-amd64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-python-minimal-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-python-minimal-amd64
ghcr.io/openhands/agent-server:7a865b1-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:7a865b1-python-minimal-arm64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-python-minimal-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-python-minimal-arm64
ghcr.io/openhands/agent-server:7a865b1-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:7a865b1-python-slim-amd64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-python-slim-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-python-slim-amd64
ghcr.io/openhands/agent-server:7a865b1-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:7a865b1-python-slim-arm64
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-python-slim-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-python-slim-arm64
ghcr.io/openhands/agent-server:7a865b1-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:7a865b1-golang
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-golang
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-golang
ghcr.io/openhands/agent-server:7a865b1-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:7a865b1-java
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-java
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-java
ghcr.io/openhands/agent-server:7a865b1-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:7a865b1-python-minimal
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-python-minimal
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-python-minimal
ghcr.io/openhands/agent-server:7a865b1-python-node-runtime-minimal
ghcr.io/openhands/agent-server:7a865b1-python-slim
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-python-slim
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-python-slim
ghcr.io/openhands/agent-server:7a865b1-python-node-runtime-slim
ghcr.io/openhands/agent-server:7a865b1-python
ghcr.io/openhands/agent-server:7a865b1cb45aada1f699e0166acd85eb21912fb4-python
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-atomic-python
ghcr.io/openhands/agent-server:7a865b1-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., 7a865b1-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 7a865b1-python-amd64) are also available if needed

Jev-Fast-Audit

⚡ Jev fast audit · estimates · 0.48s · commit 7a865b1
Strongest signal: No primary concern selected.
Evidence: No primary concern to locate.
Coverage: complete supplied coverage; 11/11 hunks, 3/3 files.

All estimates and evidence
Estimate Likelihood / value Direct evidence
SQL injection 3.0% No direct hunk selected
Command injection 5.0% No direct hunk selected
Weakened authentication 4.0% No direct hunk selected
Weakened authorization 11.0% No direct hunk selected
Contract regression 15.0% No direct hunk selected
Data loss 18.0% F002H003 · openhands-agent-server/openhands/agent_server/file_router.py:85–124
Sensitive data disclosure 4.0% No direct hunk selected
Unexpected data transfer 3.0% No direct hunk selected
Credential misuse 4.0% No direct hunk selected
Untrusted instruction authority 2.0% No direct hunk selected
Package source redirection 4.0% No direct hunk selected
Unverified remote execution 2.0% No direct hunk selected
Privileged environment access 13.0% No direct hunk selected
Security assessment bypass 4.0% No direct hunk selected
Prohibited workload 2.0% No direct hunk selected
Primary concern None selected; confidence 66.0% No primary concern to locate

_upload_file opened the destination with "wb" before streaming, so an
upload that failed part-way (disk full, a size limit, the process
killed) destroyed the previous file and left a partial one. Stream into
a sibling .<name>.<random>.tmp file and os.replace it onto the
destination only on success, removing it on failure. An overwrite keeps
the file's permissions and owner, a read-only file is still refused, and
a symlink's target is replaced as before. Drop the F14.upload-atomic
known-bug marker.

Fixes #5889

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   file_router.py4797185%204–206, 256–258, 363, 366–367, 374–375, 413, 415–417, 466, 510–513, 519–521, 527–529, 541–542, 579, 647–649, 654, 658–660, 702–703, 712, 719, 740–742, 799–801, 828–829, 833–834, 842, 844–849, 852–854, 938–939, 943–944, 1150–1152, 1156–1159
TOTAL516742047060% 

@enyst
enyst marked this pull request as ready for review October 14, 2026 01:56
@enyst
enyst requested a review from all-hands-bot October 14, 2026 02:04

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Review — PR #5902, head 2c2670e

Scope: in-repo. This changes the agent-server's own /api/file/upload handler (openhands-agent-server/.../file_router.py), which this repository owns; it does not need a product/architecture decision or a move.

State checked on this head: uv run pytest tests/agent_server/test_file_router.py → 96 passed. All GitHub Actions checks for 2c2670e are green (build, agent-server-tests, tests 24.x, agent-server-api, REST-API breakage, etc.). Linked issue #5889 is open with bug, priority:low, ready-for-dev, and its acceptance criteria match the diff. No prior review by this account exists on this head.

The core fix is sound. Streaming into a sibling .<name>.<random>.tmp and os.replace-ing it only after the stream completes makes a failed overwrite preserve the previous file and leave no temp file behind. I confirmed the failure path (previous content intact, no .*.tmp leftover), the permission-preservation path, and the symlink path locally. Two findings below; the first is material.

1. Overwrite uploads break on Windows (material)

_atomic_write calls os.chown(...) unguarded (line 113). os.chown does not exist on Windows, and contextlib.suppress(OSError) does not catch the resulting AttributeError, so every overwrite of an existing file raises and _upload_file maps it to HTTP 500. New-file uploads are unaffected (previous is None). The unit tests pass only because they run on Linux. The agent server is built and smoke-tested on Windows (build-binary-and-test matrix includes windows-latest in .github/workflows/server.yml; release-binaries.yml builds a windows-2022 binary), and file_router.py itself already carries Windows branches (_list_root_locations, os.name == "nt"). This is a supported runtime, so this is a regression introduced by the diff. Guard the call (if hasattr(os, "chown"), or contextlib.suppress(AttributeError, OSError)) or drop it.

2. Temp file is world-readable during the write

open(tmp_path, "wb") (line 108) creates the scratch file with the process umask (typically 0644) and only os.chmods it to the destination's mode after the stream finishes (line 111). When overwriting a private file (e.g. 0600), the incoming content is readable by other local users in .<name>.<random>.tmp for the duration of the upload — I measured 0644 mid-write for a 0600 target. The previous in-place open(target_path, "wb") kept 0600 throughout, so this is a confidentiality regression for private uploads. Create the temp with a restrictive mode and widen to the destination's mode just before os.replace.

🔄 CHANGES REQUESTED

if previous:
os.chmod(tmp_path, stat.S_IMODE(previous.st_mode))
with contextlib.suppress(OSError):
os.chown(tmp_path, previous.st_uid, previous.st_gid)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.chown does not exist on Windows, and contextlib.suppress(OSError) does not catch the AttributeError it raises there. Because this runs inside the try, the exception escapes _atomic_write, the temp file is cleaned up, and _upload_file returns HTTP 500 for every overwrite of an existing file on Windows (new-file uploads are fine, since previous is None). The agent server is a supported Windows runtime — build-binary-and-test covers windows-latest and a windows-2022 release binary is produced — and this file already branches on os.name == "nt". Guard the call (e.g. if hasattr(os, "chown") or contextlib.suppress(AttributeError, OSError)) or drop it.

raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(dest))
tmp_path = dest.with_name(f".{dest.name}.{uuid4().hex}.tmp")
try:
with open(tmp_path, "wb") as f:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The temp file is created with the process umask (typically 0644) and only chmod-ed to the destination's mode on line 111, after the whole stream is written. For an overwrite of a private file (e.g. 0600) the new content is therefore world-readable in .<name>.<random>.tmp for the duration of the upload — I measured 0644 mid-write for a 0600 target. The old in-place open(target_path, "wb") kept 0600 throughout, so this weakens confidentiality for private uploads. Consider creating the temp with a restrictive mode (e.g. os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)) and widening it to the destination's mode just before os.replace.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. The directory will be automatically removed after this PR merges to main through a cleanup PR.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Review — PR #5902, head 7a865b1

Scope: in-repo and correctly placed. _atomic_write changes only the agent-server's own /api/file/upload handler, which this repository owns; no OpenAPI contract change (the endpoint, request, and FileUploadResult are unchanged), so no clients/typescript/ work is required. Not a draft on the gate.

Issue #5889 acceptance: delivered. The overwrite now streams into a sibling temp file, os.replace runs only after the stream completes, failures remove the temp, and read-only targets are still refused. All four acceptance points from the issue (previous content survives a failed overwrite, no temp file left behind, permissions kept on success, atomic replacement) are covered by the new tests: test_failed_overwrite_keeps_previous_file, test_upload_overwrite_keeps_file_mode, test_upload_does_not_overwrite_read_only_file, test_upload_through_symlink_replaces_link_target. I ran uv run pytest tests/agent_server/test_file_router.py on this head in the workspace: 104 passed. The PR also carries recorded live before/after map-run evidence (xfail on main, pass on this head), which satisfies the live-evidence bar for this runtime-behavior fix. Symlink-through writes, new-file umask semantics, upload-onto-directory error mapping, and hard-link semantics (disclosed in the PR notes) all behave as documented. The F14-files.md feature-map updates match the implemented behavior.

Checks: all substantive GitHub Actions checks are green on 7a865b1. The only failing check is unresolved-review-threads, which reflects the two unresolved review threads carried over from the earlier review of this branch (on 2c2670e), not a code failure.

Blocking findings on the current head (both re-verified against 7a865b1; the code is unchanged in these spots since the earlier review, and the threads remain unresolved):

  1. Windows: every overwrite of an existing file fails with HTTP 500. os.chown does not exist on Windows; os.chown(...) raises AttributeError, which contextlib.suppress(OSError) does not catch. The exception escapes _atomic_write's try, the temp file is deleted, and _upload_file's generic handler returns 500 — so overwriting an existing file never succeeds on Windows, a regression from main's in-place write. I confirmed the escape empirically with os.chown removed, exactly reproducing the code path (write succeeds, chmod succeeds, chown raises AttributeError, temp removed). Windows is a supported agent-server runtime: build-binary-and-test builds and smoke-runs dist/openhands-agent-server.exe on windows-latest and release-binaries.yml ships a windows-2022 binary, file_router.py has no POSIX gate, and sibling agent-server modules branch on os.name. CI cannot catch this: agent-server-tests is pinned to Ubuntu and the Windows suite only runs tests/tools. Fix by guarding the call (if hasattr(os, "chown") or contextlib.suppress(AttributeError, OSError)) or dropping it.

  2. Temp file holds new content at umask permissions for the whole upload. open(tmp_path, "wb") creates the temp with the process umask (measured 0644 in this environment), and the chmod to the destination's mode only runs after the entire stream is written. For an overwrite of a private file (e.g. 0600), the new content is world-readable in .<name>.<random>.tmp for the duration of the upload — main's in-place write kept 0600 throughout, so this weakens confidentiality for private-file uploads. The same applies to a leftover temp after an abrupt kill, which sits at umask mode instead of the destination's. The repo's own atomic_write_text in openhands-sdk/utils/files.py sets the restrictive mode before writing; doing the same here (create restrictive, widen to the destination's mode just before os.replace) removes the window.

Both are fixable in a few lines without changing the design. Not approving while they stand.

🔄 CHANGES REQUESTED

if previous:
os.chmod(tmp_path, stat.S_IMODE(previous.st_mode))
with contextlib.suppress(OSError):
os.chown(tmp_path, previous.st_uid, previous.st_gid)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.chown does not exist on Windows, and contextlib.suppress(OSError) does not catch the AttributeError it raises there. Because this runs inside the try, the exception escapes _atomic_write, the temp file is removed, and _upload_file returns HTTP 500 for every overwrite of an existing file on Windows (new-file uploads are fine, since previous is None) — a regression from main's in-place write. The agent server is a supported Windows runtime: build-binary-and-test builds and smoke-runs dist/openhands-agent-server.exe on windows-latest, release-binaries.yml ships a windows-2022 binary, this file has no POSIX gate, and sibling agent-server modules branch on os.name. CI misses it because agent-server-tests is pinned to Ubuntu and the Windows suite only runs tests/tools. Guard the call (e.g. if hasattr(os, "chown") or contextlib.suppress(AttributeError, OSError)) or drop it.

raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(dest))
tmp_path = dest.with_name(f".{dest.name}.{uuid4().hex}.tmp")
try:
with open(tmp_path, "wb") as f:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The temp file is created with the process umask (typically 0644) and is only chmod-ed to the destination's mode after the whole stream is written. For an overwrite of a private file (e.g. 0600) the new content is world-readable in .<name>.<random>.tmp for the duration of the upload — I measured 0644 mid-write for a 0600 target on this head. The old in-place open(target_path, "wb") kept 0600 throughout, so this weakens confidentiality for private uploads; a temp left by an abrupt kill also sits at umask mode instead of the destination's. Consider creating the temp with a restrictive mode (e.g. os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)) and widening it to the destination's mode just before os.replace, as the repo's own atomic_write_text already sets its mode before writing.

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.

[Bug]: A failed file upload overwrite destroys the previous file and leaves a partial one

3 participants