Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Goal:
Add explicit archive size and extracted-content limits to ADR-0019 workspace transfer so oversized uploads/downloads fail fast instead of consuming unbounded memory.

Scope:
- Enforce shared workspace archive byte limits in `coding_agent.workspace_archive` for both archive creation and extraction.
- Reject oversized snapshot uploads and oversized workspace exports through the existing HTTP surfaces.
- Cover the helper and HTTP transfer paths with focused regression tests.

Out of scope:
- Do not change archive format, owner fencing, or remote client protocol.
- Do not add streaming sync or chunked upload/download.
- Do not refactor unrelated Docker workspace provider or CLI workflow code.

Context:
- ADRs:
- `docs/adr/0019-remote-client-cloud-workspace-deployment.md`
- Relevant files:
- `src/coding_agent/workspace_archive.py`
- `tests/coding_agent/environment/test_workspace_archive.py`
- `tests/ui/test_http_server_workspace_transfer.py`

Target tests:
- `uv run pytest tests/coding_agent/environment/test_workspace_archive.py -v`
- `uv run pytest tests/ui/test_http_server_workspace_transfer.py -v`

Loop policy:
- Engineer implements the smallest correct change and runs the target tests.
- Reviewer reviews only the resulting diff and affected tests.
- Reviewer reports only P1/P2 findings.
- Engineer fixes only accepted P1/P2 findings and reruns the same target tests.
- Verifier reruns the exact target tests and reports pass/fail only.

Stop conditions:
- At most one review/fix/retest cycle.
- Escalate any protocol or API-shape changes beyond size-limit hardening.
- Ignore non-blocking optimization suggestions.
156 changes: 131 additions & 25 deletions coding-agent/src/coding_agent/workspace_archive.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
from __future__ import annotations

import base64
import gzip
import io
import os
import shutil
import tarfile
import tempfile
import zlib
from pathlib import Path
from typing import Protocol, override


_PRESERVED_ROOT_NAMES = frozenset({".git"})
_MAX_WORKSPACE_ARCHIVE_BYTES = 8 * 1024 * 1024
_MAX_WORKSPACE_ARCHIVE_BASE64_CHARS = 4 * ((_MAX_WORKSPACE_ARCHIVE_BYTES + 2) // 3)
_MAX_WORKSPACE_TAR_STREAM_BYTES = 16 * 1024 * 1024
_MAX_WORKSPACE_ARCHIVE_MEMBERS = 4096


def create_workspace_archive_base64(workspace_root: Path) -> str:
Expand All @@ -17,23 +25,30 @@ def create_workspace_archive_base64(workspace_root: Path) -> str:
raise ValueError(f"workspace root does not exist: {root}")

buffer = io.BytesIO()
total_input_bytes = 0
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
for path in sorted(root.rglob("*")):
relative = path.relative_to(root)
if not relative.parts or relative.parts[0] == ".git":
continue
if path.is_symlink():
raise ValueError(f"workspace archive does not support symlinks: {relative}")
raise ValueError(
f"workspace archive does not support symlinks: {relative}"
)
if not path.is_file():
continue
stat_result = path.stat()
total_input_bytes += stat_result.st_size
_raise_if_archive_too_large(total_input_bytes)
data = path.read_bytes()
info = tarfile.TarInfo(name=relative.as_posix())
info.size = len(data)
info.mode = stat_result.st_mode & 0o777
info.mtime = int(stat_result.st_mtime)
archive.addfile(info, io.BytesIO(data))
return base64.b64encode(buffer.getvalue()).decode("ascii")
archive_bytes = buffer.getvalue()
_raise_if_archive_too_large(len(archive_bytes))
return base64.b64encode(archive_bytes).decode("ascii")


def extract_workspace_archive_base64(workspace_root: Path, archive_base64: str) -> None:
Expand All @@ -43,29 +58,40 @@ def extract_workspace_archive_base64(workspace_root: Path, archive_base64: str)
if not root.is_dir():
raise ValueError(f"workspace root is not a directory: {root}")

_raise_if_archive_base64_too_large(archive_base64)
try:
archive_bytes = base64.b64decode(archive_base64.encode("ascii"), validate=True)
except ValueError as exc:
raise ValueError("workspace archive must be valid base64") from exc
_raise_if_archive_too_large(len(archive_bytes))

with tempfile.TemporaryDirectory() as temp_dir:
temp_root = Path(temp_dir).resolve()
try:
_extract_workspace_archive_to_temp(temp_root, archive_bytes)
except (tarfile.TarError, gzip.BadGzipFile, EOFError, zlib.error) as exc:
raise ValueError("workspace archive is not a valid tar.gz") from exc

_clear_extract_target(root)
_copy_extracted_workspace(temp_root, root)


def _extract_workspace_archive_to_temp(temp_root: Path, archive_bytes: bytes) -> None:
archive_buffer = io.BytesIO(archive_bytes)
try:
with tarfile.open(fileobj=archive_buffer, mode="r:gz") as archive:
for member in archive.getmembers():
_ = _safe_archive_target(root, member.name)
if not member.isdir() and not member.isfile():
raise ValueError(
f"workspace archive only supports regular files and directories: {member.name}"
)
except tarfile.TarError as exc:
raise ValueError("workspace archive is not a valid tar.gz") from exc

_clear_extract_target(root)
_ = archive_buffer.seek(0)

with tarfile.open(fileobj=archive_buffer, mode="r:gz") as archive:
for member in archive.getmembers():
target_path = _safe_archive_target(root, member.name)
gzip_stream = gzip.GzipFile(fileobj=archive_buffer, mode="rb")
counted_stream = _CountingReader(
gzip_stream,
max_bytes=_MAX_WORKSPACE_TAR_STREAM_BYTES,
)
extracted_bytes = 0
member_count = 0
with tarfile.open(fileobj=counted_stream, mode="r|") as archive:
for member in archive:
member_count += 1
if member_count > _MAX_WORKSPACE_ARCHIVE_MEMBERS:
raise ValueError("workspace archive contains too many members")
_reject_preserved_root_member(member.name)
target_path = _safe_archive_target(temp_root, member.name)
if member.isdir():
target_path.mkdir(parents=True, exist_ok=True)
target_path.chmod(_member_mode(member))
Expand All @@ -74,13 +100,48 @@ def extract_workspace_archive_base64(workspace_root: Path, archive_base64: str)
raise ValueError(
f"workspace archive only supports regular files and directories: {member.name}"
)
if member.size < 0:
raise ValueError("workspace archive is not a valid tar.gz")
extracted_bytes += member.size
_raise_if_archive_too_large(extracted_bytes)
file_obj = archive.extractfile(member)
if file_obj is None:
raise ValueError(f"workspace archive member is unreadable: {member.name}")
target_path.parent.mkdir(parents=True, exist_ok=True)
_ = target_path.write_bytes(file_obj.read())
target_path.chmod(_member_mode(member))
os.utime(target_path, (member.mtime, member.mtime))
raise ValueError(
f"workspace archive member is unreadable: {member.name}"
)
_copy_archive_file(file_obj, target_path, member)


def _copy_archive_file(
file_obj: _ReadableBinary, target_path: Path, member: tarfile.TarInfo
) -> None:
target_path.parent.mkdir(parents=True, exist_ok=True)
copied_bytes = 0
with target_path.open("wb") as target_file:
while True:
chunk = file_obj.read(64 * 1024)
if not chunk:
break
copied_bytes += len(chunk)
if copied_bytes > member.size:
raise ValueError("workspace archive is not a valid tar.gz")
_ = target_file.write(chunk)
if copied_bytes != member.size:
raise ValueError("workspace archive is not a valid tar.gz")
target_path.chmod(_member_mode(member))
os.utime(target_path, (member.mtime, member.mtime))


def _copy_extracted_workspace(temp_root: Path, workspace_root: Path) -> None:
for source_path in sorted(temp_root.rglob("*")):
relative = source_path.relative_to(temp_root)
target_path = workspace_root / relative
if source_path.is_dir():
target_path.mkdir(parents=True, exist_ok=True)
target_path.chmod(source_path.stat().st_mode & 0o777)
continue
target_path.parent.mkdir(parents=True, exist_ok=True)
_ = shutil.copy2(source_path, target_path)


def _clear_extract_target(workspace_root: Path) -> None:
Expand All @@ -105,7 +166,9 @@ def _member_mode(member: tarfile.TarInfo) -> int:
def _safe_archive_target(workspace_root: Path, member_name: str) -> Path:
member_path = Path(member_name)
if member_path.is_absolute() or ".." in member_path.parts:
raise ValueError(f"workspace archive member escapes workspace root: {member_name}")
raise ValueError(
f"workspace archive member escapes workspace root: {member_name}"
)
if not member_path.parts:
raise ValueError("workspace archive member name must be non-empty")
candidate = (workspace_root / member_path).resolve()
Expand All @@ -116,3 +179,46 @@ def _safe_archive_target(workspace_root: Path, member_name: str) -> Path:
f"workspace archive member escapes workspace root: {member_name}"
) from exc
return candidate


def _reject_preserved_root_member(member_name: str) -> None:
member_path = Path(member_name)
if member_path.parts and member_path.parts[0] in _PRESERVED_ROOT_NAMES:
raise ValueError(
f"workspace archive must not contain preserved root entry: {member_name}"
)


def _raise_if_archive_too_large(size_bytes: int) -> None:
if size_bytes > _MAX_WORKSPACE_ARCHIVE_BYTES:
raise ValueError("workspace archive exceeds 8 MiB limit")


def _raise_if_archive_base64_too_large(archive_base64: str) -> None:
if len(archive_base64) > _MAX_WORKSPACE_ARCHIVE_BASE64_CHARS:
raise ValueError("workspace archive exceeds 8 MiB limit")


class _ReadableBinary(Protocol):
def read(self, size: int = -1, /) -> bytes: ...


class _CountingReader(io.BufferedIOBase):
def __init__(self, stream: _ReadableBinary, *, max_bytes: int) -> None:
self._stream: _ReadableBinary = stream
self._max_bytes: int = max_bytes
self._bytes_read: int = 0

@override
def readable(self) -> bool:
return True

@override
def read(self, size: int | None = -1) -> bytes:
if size is None:
size = -1
chunk = self._stream.read(size)
self._bytes_read += len(chunk)
if self._bytes_read > self._max_bytes:
raise ValueError("workspace archive exceeds 8 MiB limit")
return chunk
Loading