Skip to content
Open
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
48 changes: 48 additions & 0 deletions src/stirrup/tools/code_backends/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,10 @@ async def __aexit__(
exc_tb: object,
) -> None:
"""Stop container and cleanup temp directory."""
# Fix ownership of all files before cleanup (prevents permission errors on nested directories)
if self._container and self._temp_dir:
await self._fix_file_ownership()

# Stop and remove container
if self._container:
container = self._container # Capture for lambda type narrowing
Expand Down Expand Up @@ -623,6 +627,47 @@ async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> Comman
error_kind="execution_error",
)

async def _fix_file_ownership(self, paths: list[str] | None = None) -> None:
"""Fix ownership of files created by the container.

Files and directories created inside the Docker container run as root,
which causes permission issues when trying to move/delete them from the host.
This method runs chown inside the container to fix ownership.

Args:
paths: Specific paths to fix. If None, fixes all files in working_dir.
Paths should be container paths (absolute or relative to working_dir).
"""
if self._container is None:
return

try:
# Get the host user ID to chown to
import os
host_uid = os.getuid()
host_gid = os.getgid()

if paths:
# Fix specific paths
for path in paths:
# Normalize path - handle both relative and absolute
if not path.startswith('/'):
container_path = f"{self._working_dir}/{path}"
else:
container_path = path

# Use chown -R to handle directories recursively
chown_cmd = f"chown -R {host_uid}:{host_gid} {container_path} 2>/dev/null || true"
await self.run_command(chown_cmd, timeout=10)
else:
# Fix all files in working directory
chown_cmd = f"chown -R {host_uid}:{host_gid} {self._working_dir} 2>/dev/null || true"
await self.run_command(chown_cmd, timeout=10)

except Exception as exc:
# Don't fail the operation if chown fails, just log warning
logger.warning("Failed to fix file ownership: %s", exc)

async def save_output_files(
self,
paths: list[str],
Expand Down Expand Up @@ -662,6 +707,9 @@ async def save_output_files(
if dest_env is not None:
return await super().save_output_files(paths, output_dir, dest_env)

# Fix ownership of files before moving them (solves permission issues with nested directories)
await self._fix_file_ownership(paths)

# Local filesystem - use optimized move operation
output_dir_path = Path(output_dir)
output_dir_path.mkdir(parents=True, exist_ok=True)
Expand Down