diff --git a/coding-agent/src/coding_agent/environment/docker_workspace_provider.py b/coding-agent/src/coding_agent/environment/docker_workspace_provider.py index 5543358e8..7e01eabd5 100644 --- a/coding-agent/src/coding_agent/environment/docker_workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/docker_workspace_provider.py @@ -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" @@ -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 @@ -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" ) @@ -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 @@ -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") @@ -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 @@ -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, @@ -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], diff --git a/coding-agent/src/coding_agent/ui/http_server.py b/coding-agent/src/coding_agent/ui/http_server.py index 3d25d0b0b..32ca1badf 100644 --- a/coding-agent/src/coding_agent/ui/http_server.py +++ b/coding-agent/src/coding_agent/ui/http_server.py @@ -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, diff --git a/coding-agent/tests/coding_agent/environment/test_docker_workspace_provider.py b/coding-agent/tests/coding_agent/environment/test_docker_workspace_provider.py index 4a467b3c7..385d04aee 100644 --- a/coding-agent/tests/coding_agent/environment/test_docker_workspace_provider.py +++ b/coding-agent/tests/coding_agent/environment/test_docker_workspace_provider.py @@ -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: diff --git a/coding-agent/tests/ui/test_http_server.py b/coding-agent/tests/ui/test_http_server.py index 374bdd9bf..aeb1c5c14 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -1013,9 +1013,15 @@ async def test_create_session_rejects_workspace_provisioning_when_disabled( "cloud workspace provisioning requires cloud_workspace.enabled=true" ) - async def test_create_session_rejects_git_workspace_source_until_supported( + async def test_create_session_provisions_git_workspace_source( self, client, monkeypatch, tmp_path ): + binding = CloudWorkspaceBinding( + workspace_url="docker://agent-ws-git/workspace", + workspace_id="ws-git", + ) + captured_sources: list[dict[str, object]] = [] + monkeypatch.setattr( "coding_agent.ui.http_server._load_cloud_workspace_config", lambda: { @@ -1028,9 +1034,7 @@ async def test_create_session_rejects_git_workspace_source_until_supported( ) monkeypatch.setattr( "coding_agent.ui.http_server.provision_cloud_binding_from_config", - lambda config, source: pytest.fail( - "git workspace source should fail before provider provisioning" - ), + lambda config, source: captured_sources.append(dict(source)) or binding, ) response = await client.post( @@ -1046,10 +1050,23 @@ async def test_create_session_rejects_git_workspace_source_until_supported( }, ) - assert response.status_code == 501 - assert response.json()["detail"] == ( - "git workspace source is not implemented yet" - ) + assert response.status_code == 200 + assert captured_sources == [ + { + "kind": "git", + "remote_url": "https://github.com/org/repo.git", + "base_ref": "main", + "base_sha": "abc123", + "runtime_profile": "universal", + } + ] + session = session_manager.get_session(response.json()["session_id"]) + assert session.execution_binding == binding + assert session.origin == { + "channel": "http", + "binding_kind": "cloud", + "workspace_source_kind": "git", + } async def test_create_session_rolls_back_provisioned_workspace_on_failure( self, client, monkeypatch