Skip to content

fix(agent-server): accept the workspace cookie on the Docker-mode workspace root - #5855

Open
enyst wants to merge 3 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-docker-root-cookie
Open

enyst wants to merge 3 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-docker-root-cookie

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

 Docker cookie-or-header workspace router
+ GET /api/conversations/{id}/workspace
  GET /api/conversations/{id}/workspace/{file_path:path}
       └─ proxy to the container's matching workspace path

- slashless root fell through to header-only conversation proxy → 401
+ slashless root uses workspace auth, like workspace/ and files

Route the slashless Docker workspace root through the same cookie-or-header authentication as workspace files.

Why

Docker workspace embeds should accept the workspace session cookie with or without the trailing slash.

Issue Number

Fixes #5844

How to Test

Previously recorded verification:

Reproduction commands, results, and limits
  • uv run pytest -q tests/agent_server/docker_runtime/test_routes.py -k workspace_root_accepts: on main the [] (slashless) case fails with 401 while / and /index.html pass; on this branch all 3 pass.
  • uv run pytest -q tests/agent_server/docker_runtime tests/agent_server/test_workspace_router.py tests/agent_server/test_workspace_cookie_auth.py: 140 passed.
  • Map proof against a real server launched in Docker runtime mode (the route table and auth groups are fixed at startup, so no Docker daemon is needed; an unknown conversation shows which auth group answered):
    • main (6e7a853): control-agent-server map run --file F16 --only F16.docker-root-cookie --fresh --repeat 2 --checkout <main checkout> -> xfail twice (cookie-only GET .../workspace answers 401).
    • this branch: same command -> xpass twice (404 Conversation not found, i.e. the workspace auth group accepted the cookie and the Docker workspace handler answered).
    • after removing the marker: control-agent-server map check ok; map run --file F16 --only F16.docker-root-cookie --fresh -> pass.
  • Not run: proxying to a real conversation container (no Docker daemon in this environment). The upstream path is checked by the unit test; the inner server's slashless root is the existing local serve_workspace_root.
  • uv run pre-commit run --files <changed files>: passed.

Video/Screenshots

No UI change; reproduction evidence is above.

Type

  • Bug fix

Notes

Docker mode only. Local mode and trailing-slash clients already worked; the generated Docker-mode OpenAPI gains the slashless GET route.

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:f2193fa-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:f2193fa-golang-amd64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-golang-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-golang-amd64
ghcr.io/openhands/agent-server:f2193fa-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:f2193fa-golang-arm64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-golang-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-golang-arm64
ghcr.io/openhands/agent-server:f2193fa-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:f2193fa-java-amd64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-java-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-java-amd64
ghcr.io/openhands/agent-server:f2193fa-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:f2193fa-java-arm64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-java-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-java-arm64
ghcr.io/openhands/agent-server:f2193fa-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:f2193fa-python-amd64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-python-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-python-amd64
ghcr.io/openhands/agent-server:f2193fa-python-node-runtime-amd64
ghcr.io/openhands/agent-server:f2193fa-python-arm64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-python-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-python-arm64
ghcr.io/openhands/agent-server:f2193fa-python-node-runtime-arm64
ghcr.io/openhands/agent-server:f2193fa-python-minimal-amd64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-python-minimal-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-python-minimal-amd64
ghcr.io/openhands/agent-server:f2193fa-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:f2193fa-python-minimal-arm64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-python-minimal-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-python-minimal-arm64
ghcr.io/openhands/agent-server:f2193fa-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:f2193fa-python-slim-amd64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-python-slim-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-python-slim-amd64
ghcr.io/openhands/agent-server:f2193fa-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:f2193fa-python-slim-arm64
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-python-slim-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-python-slim-arm64
ghcr.io/openhands/agent-server:f2193fa-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:f2193fa-golang
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-golang
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-golang
ghcr.io/openhands/agent-server:f2193fa-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:f2193fa-java
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-java
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-java
ghcr.io/openhands/agent-server:f2193fa-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:f2193fa-python-minimal
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-python-minimal
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-python-minimal
ghcr.io/openhands/agent-server:f2193fa-python-node-runtime-minimal
ghcr.io/openhands/agent-server:f2193fa-python-slim
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-python-slim
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-python-slim
ghcr.io/openhands/agent-server:f2193fa-python-node-runtime-slim
ghcr.io/openhands/agent-server:f2193fa-python
ghcr.io/openhands/agent-server:f2193fa0f10a9c804bd2275c875572c9dab2f5d0-python
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-docker-root-cookie-python
ghcr.io/openhands/agent-server:f2193fa-python-node-runtime

About Multi-Architecture Support

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

Jev-Fast-Audit

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

All estimates and evidence
Estimate Likelihood / value Direct evidence
SQL injection 3.0% No direct hunk selected
Command injection 4.0% No direct hunk selected
Weakened authentication 17.0% F002H001 · openhands-agent-server/openhands/agent_server/docker_runtime/routers.py:389–412
Weakened authorization 11.0% No direct hunk selected
Contract regression 12.0% No direct hunk selected
Data loss 3.0% No direct hunk selected
Sensitive data disclosure 5.0% No direct hunk selected
Unexpected data transfer 4.0% No direct hunk selected
Credential misuse 9.0% No direct hunk selected
Untrusted instruction authority 3.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 2.0% No direct hunk selected
Security assessment bypass 7.0% No direct hunk selected
Prohibited workload 2.0% No direct hunk selected
Primary concern None selected; confidence 82.0% No primary concern to locate

…kspace root

In Docker runtime mode the cookie-or-header workspace router only declared
/{conversation_id}/workspace/{file_path:path}, so GET .../workspace (no
trailing slash) fell through to the header-only conversation proxy and a
cookie-only request got 401. Declare the slashless root on the same
handler, proxying it to the container's /workspace root as local mode
serves it, and drop the F16.docker-root-cookie known-bug marker.

Fixes #5844

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/docker_runtime
   routers.py2396374%57, 65, 72–74, 110–112, 127–128, 130, 139, 147–148, 168, 185, 188–191, 194–198, 206, 231–232, 265, 274, 284, 290–292, 302–304, 313, 316, 361, 371–372, 442, 444–456, 459–461, 467–468, 479, 488
TOTAL516542044760% 

@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: this change belongs in this repository (agent-server Docker runtime, which this repo owns) and the direction is right — the slashless GET /api/conversations/{id}/workspace now lands in the cookie-or-header docker_workspace_router group instead of falling through to the header-only catch-all. Local verification: uv run pytest -q tests/agent_server/docker_runtime tests/agent_server/test_workspace_router.py tests/agent_server/test_workspace_cookie_auth.py -> 140 passed, and all 27 non-skipped checks on the head are green (OpenAPI quality, build, coverage, workspace tests).

One material finding is attached inline. Making file_path an optional parameter of the root route has a side effect the PR does not intend: because the root route (/{conversation_id}/workspace) has no {file_path} path segment, FastAPI infers file_path as a query parameter for that operation. As a result, GET .../workspace?file_path=<anything> is accepted and silently rewritten to an upstream file path — a request that previously received 401 for a cookie-only caller now proxies to a different resource, and file_path=../../... is forwarded verbatim. This is a small security/contract widening of the workspace serving path (the very surface this PR is tightening), it leaks into the generated OpenAPI schema, and it is not covered by the new test. Details and a suggested shape are in the inline comment.

Because the new test asserts only client.get(path) without query parameters, it does not exercise this path, and the behavior is easy to miss. Please address before merge.

🔄 CHANGES REQUESTED

@docker_workspace_router.get("/{conversation_id}/workspace/{file_path:path}")
async def proxy_workspace_file(
conversation_id: UUID, file_path: str, request: Request
conversation_id: UUID, request: Request, file_path: str | None = None

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.

Making file_path optional here has an unintended side effect on the new root route. The decorator above adds /{conversation_id}/workspace, which has no {file_path} path segment, so FastAPI binds file_path as a query parameter for that operation (confirmed in the app's OpenAPI: GET /api/conversations/{conversation_id}/workspace lists file_path with in: query). At runtime this means GET .../workspace?file_path=X is accepted and rewrites the upstream path to .../workspace/X:

GET /api/conversations/<id>/workspace?file_path=secret.txt
  -> upstream /api/conversations/<id>/workspace/secret.txt?file_path=secret.txt
GET /api/conversations/<id>/workspace?file_path=../../etc/passwd
  -> upstream /api/conversations/<id>/workspace/../../etc/passwd?file_path=..%2F..%2Fetc%2Fpasswd

Before this change that URL was not served by this handler (cookie-only callers got 401), so this is a behavior widening introduced by the fix, on the workspace-serving surface the PR is trying to make consistent. It also leaks a spurious file_path query parameter into the generated OpenAPI contract.

Suggested fix: keep the two routes on separate handlers (mirroring workspace_router.serve_workspace_root / serve_workspace_file), or read the path from the request rather than adding an optional parameter — e.g. compute upstream = _upstream_path(request, request.url.path) for the root handler and leave the file handler unchanged. Adding a test that asserts a query-only ?file_path= request does not redirect to a file path would lock this down.

…-cookie

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

@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: this change belongs in this repository (agent-server Docker runtime, which this repo owns) and the direction is right — the slashless GET /api/conversations/{id}/workspace now lands in the cookie-or-header docker_workspace_router group instead of falling through to the header-only catch-all. The PR's own diff (against merge-base 5fa4dea) is unchanged from the earlier revision: only routers.py, its test, and the F16 feature-map prose. Head f2987f2 is a merge of main into the branch.

Local verification on this head: uv run pytest -q tests/agent_server/docker_runtime tests/agent_server/test_workspace_router.py tests/agent_server/test_workspace_cookie_auth.py -> 146 passed, and all 30 checks on the head are green.

The one material finding from the earlier review still reproduces on this head and remains unaddressed, so it is repeated inline. Making file_path an optional parameter of the root route has a side effect the PR does not intend: because the root route (/{conversation_id}/workspace) has no {file_path} path segment, FastAPI infers file_path as a query parameter for that operation. As a result, GET .../workspace?file_path=<anything> is accepted and silently rewritten to an upstream file path — a request that previously received 401 for a cookie-only caller now proxies to a different resource, and file_path=../../... is forwarded verbatim. This is a small security/contract widening of the workspace-serving surface this PR is tightening, it leaks a spurious file_path query parameter into the generated OpenAPI schema, and it is not covered by the new test (which calls client.get(path) with no query string). Please address before merge.

Unrelated to the finding: the PR is currently mergeable: false / mergeable_state: dirty against main (merge conflict), so it will need a rebase before it can merge.

🔄 CHANGES REQUESTED

@docker_workspace_router.get("/{conversation_id}/workspace/{file_path:path}")
async def proxy_workspace_file(
conversation_id: UUID, file_path: str, request: Request
conversation_id: UUID, request: Request, file_path: str | None = None

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.

Making file_path optional here has an unintended side effect on the new root route. The decorator above adds /{conversation_id}/workspace, which has no {file_path} path segment, so FastAPI binds file_path as a query parameter for that operation (confirmed in the app's OpenAPI on this head: GET /api/conversations/{conversation_id}/workspace lists file_path with in: query). At runtime this means GET .../workspace?file_path=X is accepted and rewrites the upstream path to .../workspace/X (reproduced on this head):

GET /api/conversations/<id>/workspace?file_path=secret.txt
  -> upstream /api/conversations/<id>/workspace/secret.txt?file_path=secret.txt
GET /api/conversations/<id>/workspace?file_path=../../etc/passwd
  -> upstream /api/conversations/<id>/workspace/../../etc/passwd?file_path=..%2F..%2Fetc%2Fpasswd

Before this change that URL was not served by this handler (cookie-only callers got 401), so this is a behavior widening introduced by the fix, on the workspace-serving surface the PR is trying to make consistent. It also leaks a spurious file_path query parameter into the generated OpenAPI contract.

Suggested fix: keep the two routes on separate handlers (mirroring workspace_router.serve_workspace_root / serve_workspace_file), or read the path from the request rather than adding an optional parameter — e.g. compute upstream = _upstream_path(request, request.url.path) for the root handler and leave the file handler unchanged. Adding a test that asserts a query-only ?file_path= request does not redirect to a file path would lock this down.

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.

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]: in Docker runtime mode, the slashless workspace root rejects the workspace cookie with 401

3 participants