Skip to content

fix(agent-server): answer uploads onto a directory or below a file with 400 - #5931

Merged
enyst merged 2 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-upload-onto-directory
Oct 14, 2026
Merged

enyst merged 2 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-upload-onto-directory

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:

I'm an AI agent (Claude Code) helping Engel Nyst (@enyst) with project maintenance.

Requested by Engel · project thread

False Decision needed

Which status code a wrong upload destination returns. I implemented 400. The issue triage asks for 400, and POST /api/file/create_directory already answers the same collisions (existing non-directory, parent is a file) with 400 Path exists and is not a directory. The alternative is 409 Conflict when the target is an existing directory. With 409, upload and create_directory would disagree unless create_directory changed too. Neither choice needs a client change, because both clients (RemoteWorkspace.file_upload in the Python SDK and fileUpload in clients/typescript) treat any non-2xx as a failed upload.

Why

Uploading with POST /api/file/upload onto an existing directory, or to a path whose parent is a regular file, returned 500 Failed to upload file: [Errno 21] Is a directory / [Errno 17] File exists. Callers could not tell a mistake in their own path from a server fault.

Summary

  • _upload_file now maps IsADirectoryError to 400 Path is a directory, and FileExistsError / NotADirectoryError (which mkdir(parents=True) raises when a parent is a file) to 400 A parent of the path is not a directory. Every other failure still returns 500. The conversation-scoped route POST /api/conversations/{id}/file/upload mounts the same handler.
  • Tests: a parametrised test on the global route covers a directory target, a direct parent that is a file, and an ancestor further up that is a file. A conversation-scoped test covers both collisions. Every test also asserts that nothing on disk changed.
  • Feature map: F14.upload-onto-directory no longer has its # bug markers and now asserts the exact 400 details.

Issue Number

Fixes #5930

How to Test

  • uv run pytest tests/agent_server/test_file_router.py: 94 passed, 1 skipped. uv run pytest tests/agent_server/test_runtime_router.py: 7 passed. Both new tests fail on main (500) and pass here.
  • Map proof (AGENT_SERVER_VERIFY_HOME=/var/tmp/avs-upload-onto-directory):
    • main: control-agent-server map run --file F14 --only F14.upload-onto-directory --fresh --repeat 2 --checkout <main checkout> -> xfail x2;
    • this branch, marker still in place: the same command against this checkout -> xpass x2;
    • after dropping the marker: control-agent-server map check ok (34 files, 935 ids), and map run --only F14.upload-onto-directory --fresh -> pass.
  • uv run pre-commit run --files <changed files>: all passed.

Video/Screenshots

n/a

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

Expect small merge overlaps with two open PRs that also touch _upload_file and the F14 map: #5856 (upload file_size) and #5902 (atomic overwrite). This change only adds except branches, so it combines cleanly with both. With the atomic write, a directory target raises IsADirectoryError at the replace step, and this change maps it the same way.


🐳 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:0f38cdb-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:0f38cdb-golang-amd64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-golang-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-golang-amd64
ghcr.io/openhands/agent-server:0f38cdb-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:0f38cdb-golang-arm64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-golang-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-golang-arm64
ghcr.io/openhands/agent-server:0f38cdb-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:0f38cdb-java-amd64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-java-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-java-amd64
ghcr.io/openhands/agent-server:0f38cdb-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:0f38cdb-java-arm64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-java-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-java-arm64
ghcr.io/openhands/agent-server:0f38cdb-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:0f38cdb-python-amd64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-python-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-python-amd64
ghcr.io/openhands/agent-server:0f38cdb-python-node-runtime-amd64
ghcr.io/openhands/agent-server:0f38cdb-python-arm64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-python-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-python-arm64
ghcr.io/openhands/agent-server:0f38cdb-python-node-runtime-arm64
ghcr.io/openhands/agent-server:0f38cdb-python-minimal-amd64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-python-minimal-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-python-minimal-amd64
ghcr.io/openhands/agent-server:0f38cdb-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:0f38cdb-python-minimal-arm64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-python-minimal-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-python-minimal-arm64
ghcr.io/openhands/agent-server:0f38cdb-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:0f38cdb-python-slim-amd64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-python-slim-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-python-slim-amd64
ghcr.io/openhands/agent-server:0f38cdb-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:0f38cdb-python-slim-arm64
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-python-slim-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-python-slim-arm64
ghcr.io/openhands/agent-server:0f38cdb-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:0f38cdb-golang
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-golang
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-golang
ghcr.io/openhands/agent-server:0f38cdb-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:0f38cdb-java
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-java
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-java
ghcr.io/openhands/agent-server:0f38cdb-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:0f38cdb-python-minimal
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-python-minimal
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-python-minimal
ghcr.io/openhands/agent-server:0f38cdb-python-node-runtime-minimal
ghcr.io/openhands/agent-server:0f38cdb-python-slim
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-python-slim
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-python-slim
ghcr.io/openhands/agent-server:0f38cdb-python-node-runtime-slim
ghcr.io/openhands/agent-server:0f38cdb-python
ghcr.io/openhands/agent-server:0f38cdbab22d85949ac1c845022763663200c781-python
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-upload-onto-directory-python
ghcr.io/openhands/agent-server:0f38cdb-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., 0f38cdb-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., 0f38cdb-python-amd64) are also available if needed

…th 400

_upload_file mapped every non-HTTP exception to 500, so uploading onto an
existing directory ([Errno 21]) or to a path whose parent is a file
([Errno 17] / [Errno 20]) looked like a server fault. Map IsADirectoryError
to 400 "Path is a directory" and FileExistsError/NotADirectoryError to 400
"A parent of the path is not a directory", matching create_directory, and
drop the F14.upload-onto-directory known-bug marker.

Fixes #5930

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
@enyst enyst self-assigned this Oct 14, 2026
@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.py4507583%127–129, 165–167, 217–219, 324, 327–328, 335–336, 374, 376–378, 427, 471–474, 480–482, 488–490, 502–503, 540, 608–610, 615, 619–621, 663–664, 673, 680, 701–703, 760–762, 789–790, 794–795, 803, 805–810, 813–815, 899–900, 904–905, 941, 1089–1091, 1095–1098
TOTAL497421982660% 

…d route

Fixes #5930

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

@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).

Scope

In scope. The change belongs in openhands-agent-server: it edits the canonical REST handler _upload_file in openhands-agent-server/openhands/agent_server/file_router.py, which this repository owns per the cross-repo boundaries in AGENTS.md. No product/architecture decision is needed — the linked issue #5930 (labels bug, priority:low, ready-for-dev) already fixed the intended status code as 400, consistent with create_directory in the same file.

Findings

No material correctness, security, or compatibility defects found.

Verified against current head 0f38cdba:

  • The two new except branches are disjoint and correctly typed. IsADirectoryError maps to 400 Path is a directory; (FileExistsError, NotADirectoryError) maps to 400 A parent of the path is not a directory. Neither class is a subclass of the other (IsADirectoryError/NotADirectoryError derive directly from OSError), so branch ordering is not load-bearing.
  • The raised exception types match the actual code paths: on this Python (3.13), mkdir(parents=True, exist_ok=True) raises FileExistsError when the direct parent is a file and NotADirectoryError when an ancestor further up is one, while open(target, "wb") raises IsADirectoryError for a directory target. Every other failure still falls through to the 500 branch, so genuine I/O faults remain server errors.
  • The conversation-scoped route mounts the same handler, so the single fix covers POST /api/conversations/{id}/file/upload as well.
  • No compatibility concern: the route's non-2xx contract is unchanged, and both callers (RemoteWorkspace.file_upload in the Python SDK and fileUpload in clients/typescript) already treat any non-2xx as a failed upload.

Acceptance criteria in #5930 are all met: directory target to 400 with the directory unchanged; direct-parent file to 400 with the file unchanged; deeper ancestor-file to 400; non-empty, cause-identifying detail; non-path failures still 500; conversation-scoped route returns the same 400; success behavior unchanged.

Verification

  • GitHub Actions for head 0f38cdba: all required checks green (agent-server-tests, agent-server-api, pre-commit, sdk-tests, integration-test, etc.).
  • Ran the focused suites locally on the checked-out head: uv run pytest tests/agent_server/test_file_router.py -> 95 passed; uv run pytest tests/agent_server/test_runtime_router.py -> 7 passed. The new tests drive the real FastAPI app (TestClient / ASGITransport), assert 400 plus the exact detail, and assert nothing on disk changed.

✅ APPROVED

@enyst
enyst merged commit 75105bd into main Oct 14, 2026
61 of 63 checks passed
@enyst
enyst deleted the claude/umbrella-bug-fixes-tgz9ai-upload-onto-directory branch October 14, 2026 20:24
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]: POST /api/file/upload onto an existing directory, or below a file, returns a masked 500 instead of 400

3 participants