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
114 changes: 105 additions & 9 deletions coding-agent/src/coding_agent/environment/docker_workspace_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
_DOCKER_TIMEOUT_SENTINEL_PREFIX = "__CODING_AGENT_DOCKER_TIMEOUT__:"
_DOCKER_CONTAINER_REMOVAL_WAIT_SECONDS = 5.0
_DOCKER_CONTAINER_REMOVAL_POLL_INTERVAL_SECONDS = 0.1
_GIT_OPERATION_TIMEOUT_SECONDS = 300
_GIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{4,64}$")
_DEFAULT_DOCKER_IMAGE = "python:3.11-slim"
_DEFAULT_DOCKER_CPUS = "1"
_DEFAULT_DOCKER_MEMORY = "512m"
Expand All @@ -64,6 +66,7 @@ class _DockerWorkspaceProviderConfig:
container_workspace_root: str
container_name_prefix: str
docker_binary: str
git_binary: str
env_allowlist: tuple[str, ...]
exec_user: str | None
image: str
Expand Down Expand Up @@ -372,7 +375,7 @@ def provision_cloud_workspace_binding(
from ..ui.execution_binding import CloudWorkspaceBinding

kind = source.get("kind")
if kind != "docker":
if kind not in {"docker", "git"}:
raise ValueError(
"cloud workspace source kind is not supported for provider=docker"
)
Expand Down Expand Up @@ -400,16 +403,23 @@ def provision_cloud_workspace_binding(
)
docker_cleanup_needed = False
try:
snapshot_archive_base64 = source.get("snapshot_archive_base64")
if snapshot_archive_base64 is not None:
if not isinstance(snapshot_archive_base64, str):
raise ValueError(
"workspace_source.snapshot_archive_base64 must be a string"
)
extract_workspace_archive_base64(
if kind == "git":
_clone_git_workspace_source(
provider_config,
source,
workspace_root,
snapshot_archive_base64,
)
else:
snapshot_archive_base64 = source.get("snapshot_archive_base64")
if snapshot_archive_base64 is not None:
if not isinstance(snapshot_archive_base64, str):
raise ValueError(
"workspace_source.snapshot_archive_base64 must be a string"
)
extract_workspace_archive_base64(
workspace_root,
snapshot_archive_base64,
)

def mark_docker_cleanup_needed() -> None:
nonlocal docker_cleanup_needed
Expand Down Expand Up @@ -635,6 +645,7 @@ def _docker_workspace_provider_config(
effective_config.get("container_name_prefix"), ""
)
docker_binary = _optional_string(effective_config.get("docker_binary"), "docker")
git_binary = _optional_string(effective_config.get("git_binary"), "git")
exec_user = _optional_string(effective_config.get("exec_user"), None)
env_allowlist = tuple(
_string_list(effective_config.get("env_allowlist"), key="env_allowlist")
Expand Down Expand Up @@ -665,6 +676,7 @@ def _docker_workspace_provider_config(
)
assert container_name_prefix is not None
assert docker_binary is not None
assert git_binary is not None
assert network is not None
assert cpus is not None
assert memory is not None
Expand All @@ -678,6 +690,7 @@ def _docker_workspace_provider_config(
container_workspace_root=container_workspace_root,
container_name_prefix=container_name_prefix,
docker_binary=docker_binary,
git_binary=git_binary,
env_allowlist=env_allowlist,
exec_user=exec_user,
image=image,
Expand Down Expand Up @@ -1063,6 +1076,89 @@ def _docker_provider_ready(provider_config: _DockerWorkspaceProviderConfig) -> b
return result.returncode == 0


def _clone_git_workspace_source(
provider_config: _DockerWorkspaceProviderConfig,
source: CloudWorkspaceSource,
workspace_root: Path,
) -> None:
remote_url = source.get("remote_url")
base_ref = source.get("base_ref")
base_sha = source.get("base_sha")
if not isinstance(remote_url, str) or not remote_url.strip():
raise ValueError("workspace_source.remote_url is required for kind=git")
if not isinstance(base_ref, str) or not base_ref.strip():
raise ValueError("workspace_source.base_ref is required for kind=git")
if not isinstance(base_sha, str) or not base_sha.strip():
raise ValueError("workspace_source.base_sha is required for kind=git")
remote_url = remote_url.strip()
base_ref = base_ref.strip()
base_sha = base_sha.strip()
if _GIT_SHA_RE.fullmatch(base_sha) is None:
raise ValueError("workspace_source.base_sha must be a hex git commit SHA")

clone_command = [
provider_config.git_binary,
"clone",
"--no-checkout",
"--branch",
base_ref,
"--",
remote_url,
str(workspace_root),
]
git_env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
try:
_ = subprocess.run(
clone_command,
shell=False,
capture_output=True,
text=True,
timeout=_GIT_OPERATION_TIMEOUT_SECONDS,
stdin=subprocess.DEVNULL,
env=git_env,
check=True,
)
except subprocess.CalledProcessError as exc:
_add_git_failure_output_notes(exc, "git clone", exc.stdout, exc.stderr)
raise
checkout_command = [
provider_config.git_binary,
"-C",
str(workspace_root),
"checkout",
"--detach",
base_sha,
]
try:
_ = subprocess.run(
checkout_command,
shell=False,
capture_output=True,
text=True,
timeout=_GIT_OPERATION_TIMEOUT_SECONDS,
stdin=subprocess.DEVNULL,
env=git_env,
check=True,
)
except subprocess.CalledProcessError as exc:
_add_git_failure_output_notes(exc, "git checkout", exc.stdout, exc.stderr)
raise


def _add_git_failure_output_notes(
exc: BaseException,
operation: str,
stdout: object,
stderr: object,
) -> None:
stdout_text = _redact_setup_output(stdout, {})
stderr_text = _redact_setup_output(stderr, {})
if stdout_text:
exc.add_note(f"{operation} stdout:\n{stdout_text}")
if stderr_text:
exc.add_note(f"{operation} stderr:\n{stderr_text}")


def _run_docker_setup_phase_if_configured(
provider_config: _DockerWorkspaceProviderConfig,
config: dict[str, object],
Expand Down
5 changes: 0 additions & 5 deletions coding-agent/src/coding_agent/ui/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,11 +768,6 @@ def _provisioned_execution_binding_from_request(
raise ValueError(
"cloud workspace provisioning requires cloud_workspace.enabled=true"
)
if body.workspace_source.kind == "git":
raise HTTPException(
status_code=501,
detail="git workspace source is not implemented yet",
)
_validate_workspace_source_phase_policy(
body.workspace_source.model_dump(mode="python"),
cloud_workspace_config,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,172 @@ def fake_run(
) == "uploaded\n"


def test_docker_workspace_provider_clones_git_source_before_setup_phase(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
workspace_root = tmp_path / "workspaces"
workspace_root.mkdir()
events: list[str] = []

def fake_run(
command: list[str], **kwargs: object
) -> subprocess.CompletedProcess[str]:
if command[:4] == ["/usr/bin/git", "clone", "--no-checkout", "--branch"]:
assert command[4:7] == ["main", "--", "https://github.com/org/repo.git"]
assert kwargs["stdin"] is subprocess.DEVNULL
env = kwargs["env"]
assert isinstance(env, dict)
assert env["GIT_TERMINAL_PROMPT"] == "0"
target = Path(command[-1])
_ = (target / ".git").mkdir()
_ = (target / "README.md").write_text("cloned\n", encoding="utf-8")
events.append("clone")
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
if command[0:2] == ["/usr/bin/git", "-C"] and command[3:5] == [
"checkout",
"--detach",
]:
assert command[5] == "abc123"
assert kwargs["stdin"] is subprocess.DEVNULL
env = kwargs["env"]
assert isinstance(env, dict)
assert env["GIT_TERMINAL_PROMPT"] == "0"
checkout_root = Path(command[2])
assert (checkout_root / ".git").is_dir()
assert (checkout_root / "README.md").read_text(
encoding="utf-8"
) == "cloned\n"
events.append("checkout")
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
del kwargs
if command[1:3] == ["run", "--rm"]:
workspace_mount = command[command.index("-v") + 1]
host_workspace = Path(workspace_mount.split(":", 1)[0])
assert (host_workspace / ".git").is_dir()
assert (host_workspace / "README.md").read_text(
encoding="utf-8"
) == "cloned\n"
events.append("setup")
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
if command[1:3] == ["run", "-d"]:
events.append("agent")
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
raise AssertionError(f"unexpected command: {command}")

monkeypatch.setattr(subprocess, "run", fake_run)

binding = provision_cloud_binding_from_config(
_docker_config(
{
"workspace_root": str(workspace_root),
"docker_binary": "/usr/bin/docker",
"git_binary": "/usr/bin/git",
"remote_phases": {
"setup": {
"enabled": True,
"network": "bridge",
"timeout_seconds": 600,
"commands": ["test -d .git"],
"secret_env_allowlist": [],
},
"agent": {"network": "none"},
},
}
),
{
"kind": "git",
"remote_url": "https://github.com/org/repo.git",
"base_ref": "main",
"base_sha": "abc123",
},
)

assert binding.workspace_id.startswith("ws-")
assert events == ["clone", "checkout", "setup", "agent"]


def test_docker_workspace_provider_rejects_invalid_git_base_sha_before_subprocess(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
workspace_root = tmp_path / "workspaces"
workspace_root.mkdir()
docker_run_called = False

def fake_run(
command: list[str], **kwargs: object
) -> subprocess.CompletedProcess[str]:
nonlocal docker_run_called
del command, kwargs
docker_run_called = True
raise AssertionError("git or docker subprocess should not run")

monkeypatch.setattr(subprocess, "run", fake_run)

with pytest.raises(
ValueError, match="workspace_source.base_sha must be a hex git commit SHA"
):
_ = provision_cloud_binding_from_config(
_docker_config(
{
"workspace_root": str(workspace_root),
"docker_binary": "/usr/bin/docker",
"git_binary": "/usr/bin/git",
}
),
{
"kind": "git",
"remote_url": "-not-a-flag",
"base_ref": "main",
"base_sha": "--detach",
},
)

assert docker_run_called is False
assert list(workspace_root.iterdir()) == []


def test_docker_workspace_provider_adds_git_output_notes_on_clone_failure(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
workspace_root = tmp_path / "workspaces"
workspace_root.mkdir()

def fake_run(
command: list[str], **kwargs: object
) -> subprocess.CompletedProcess[str]:
del kwargs
raise subprocess.CalledProcessError(
returncode=128,
cmd=command,
output="clone out",
stderr="clone err",
)

monkeypatch.setattr(subprocess, "run", fake_run)

with pytest.raises(subprocess.CalledProcessError) as exc_info:
_ = provision_cloud_binding_from_config(
_docker_config(
{
"workspace_root": str(workspace_root),
"docker_binary": "/usr/bin/docker",
"git_binary": "/usr/bin/git",
}
),
{
"kind": "git",
"remote_url": "https://github.com/org/repo.git",
"base_ref": "main",
"base_sha": "abc123",
},
)

notes = "\n".join(getattr(exc_info.value, "__notes__", []))
assert "git clone stdout:\nclone out" in notes
assert "git clone stderr:\nclone err" in notes
assert list(workspace_root.iterdir()) == []


def test_docker_workspace_provider_removes_workspace_when_snapshot_import_fails_before_containers(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
Expand Down
Loading