diff --git a/coding-agent/docs/remote-sandbox-production.md b/coding-agent/docs/remote-sandbox-production.md index 13dd8d564..5e0faf4e2 100644 --- a/coding-agent/docs/remote-sandbox-production.md +++ b/coding-agent/docs/remote-sandbox-production.md @@ -85,6 +85,9 @@ git_author_email = "coding-agent@example.com" allowed_git_hosts = ["github.com"] git_token_env = "CODING_AGENT_GIT_TOKEN" +[remote_sources.git] +allowed_hosts = ["github.com"] + [remote_publication.github] enabled = true token_env = "CODING_AGENT_GITHUB_TOKEN" @@ -254,11 +257,13 @@ coding-agent remote publish team --session --branch coding-agent/se Branch publication requires `[remote_publication]` to be enabled, Git author identity to be configured, the workspace `remote.origin.url` host to match `allowed_git_hosts`, and server-side Git credentials. The host allowlist is -checked before token injection or `git push`. When `git_token_env` is set, the -named environment variable must contain a Git token; the server injects it into -the `git push` environment without placing the token in command arguments or -publication responses. If `git_token_env` is omitted, the workspace must rely -on existing Git credential helper state. +checked before token injection or `git push`. Git workspace cloning uses +`[remote_sources.git].allowed_hosts` and rejects disallowed source URLs before +`git clone`. When `git_token_env` is set, the named environment variable must +contain a Git token; the server injects it into the `git push` environment +without placing the token in command arguments or publication responses. If +`git_token_env` is omitted, the workspace must rely on existing Git credential +helper state. GitHub PR publication currently supports `github.com` remotes. It requires `remote_publication.github.enabled = true`, a non-empty `token_env`, and 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 a894ff06d..36eb27110 100644 --- a/coding-agent/src/coding_agent/environment/docker_workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/docker_workspace_provider.py @@ -9,6 +9,7 @@ import secrets import shutil import subprocess +import tempfile import threading import time import uuid @@ -412,6 +413,7 @@ def provision_cloud_workspace_binding( if kind == "git": _clone_git_workspace_source( provider_config, + config, source, workspace_root, ) @@ -659,12 +661,7 @@ def workspace_patch( workspace_id, "patch", ) - patch = _run_git_workspace_command( - provider_config, - workspace_root, - ["diff", "--binary", "HEAD", "--"], - "git diff", - ) + patch = _git_workspace_patch(provider_config, workspace_root) return WorkspacePatch( workspace_id=workspace_id, format="unified_diff", @@ -1120,18 +1117,24 @@ def _git_workspace_diff( workspace_id: str, workspace_root: Path, ) -> WorkspaceDiff: - name_status_output = _run_git_workspace_command( - provider_config, - workspace_root, - ["diff", "--name-status", "--find-renames", "-z", "HEAD", "--"], - "git diff --name-status", - ) - numstat_output = _run_git_workspace_command( - provider_config, - workspace_root, - ["diff", "--numstat", "HEAD", "--"], - "git diff --numstat", - ) + index_env = _prepare_git_result_index(provider_config, workspace_root) + try: + name_status_output = _run_git_workspace_command( + provider_config, + workspace_root, + ["diff", "--cached", "--name-status", "--find-renames", "-z", "HEAD", "--"], + "git diff --cached --name-status", + extra_env=index_env, + ) + numstat_output = _run_git_workspace_command( + provider_config, + workspace_root, + ["diff", "--cached", "--numstat", "HEAD", "--"], + "git diff --cached --numstat", + extra_env=index_env, + ) + finally: + _remove_git_result_index(index_env) numstat = _parse_git_numstat(numstat_output) files = _parse_git_name_status(name_status_output, numstat) additions = sum(item[0] for item in numstat.values() if item[0] is not None) @@ -1144,6 +1147,65 @@ def _git_workspace_diff( ) +def _git_workspace_patch( + provider_config: _DockerWorkspaceProviderConfig, + workspace_root: Path, +) -> str: + index_env = _prepare_git_result_index(provider_config, workspace_root) + try: + return _run_git_workspace_command( + provider_config, + workspace_root, + ["diff", "--cached", "--binary", "HEAD", "--"], + "git diff --cached", + extra_env=index_env, + ) + finally: + _remove_git_result_index(index_env) + + +def _prepare_git_result_index( + provider_config: _DockerWorkspaceProviderConfig, + workspace_root: Path, +) -> dict[str, str]: + git_dir = workspace_root / ".git" + with tempfile.NamedTemporaryFile( + prefix="coding-agent-result-index-", + dir=git_dir if git_dir.is_dir() else None, + delete=False, + ) as temp_index: + temp_index_path = Path(temp_index.name) + + existing_index = git_dir / "index" + if existing_index.is_file(): + shutil.copy2(existing_index, temp_index_path) + index_env = {"GIT_INDEX_FILE": str(temp_index_path)} + if not existing_index.is_file(): + _run_git_workspace_command( + provider_config, + workspace_root, + ["read-tree", "HEAD"], + "git read-tree", + extra_env=index_env, + ) + _run_git_workspace_command( + provider_config, + workspace_root, + ["add", "-A"], + "git add", + extra_env=index_env, + ) + return index_env + + +def _remove_git_result_index(index_env: dict[str, str]) -> None: + index_path = Path(index_env["GIT_INDEX_FILE"]) + try: + index_path.unlink() + except FileNotFoundError: + return + + def _publish_git_workspace_branch( provider_config: _DockerWorkspaceProviderConfig, publication_config: dict[str, object], @@ -1182,6 +1244,14 @@ def _publish_git_workspace_branch( ["check-ref-format", "--branch", branch_name], "git check-ref-format", ) + remote_url = _run_git_publish_command( + provider_config, + workspace_root, + ["config", "--get", "remote.origin.url"], + "git config remote.origin.url", + ).strip() + _validate_publication_remote_url(publication_config, remote_url) + push_env = _git_publication_push_env(publication_config) _run_git_publish_command( provider_config, workspace_root, @@ -1212,15 +1282,7 @@ def _publish_git_workspace_branch( ["rev-parse", "HEAD"], "git rev-parse", ).strip() - remote_url = _run_git_publish_command( - provider_config, - workspace_root, - ["config", "--get", "remote.origin.url"], - "git config remote.origin.url", - ).strip() - _validate_publication_remote_url(publication_config, remote_url) pushed_ref = f"refs/heads/{branch_name}" - push_env = _git_publication_push_env(publication_config) _run_git_publish_command( provider_config, workspace_root, @@ -1281,8 +1343,6 @@ def _git_publication_push_env( def _redact_git_remote_url(remote_url: str) -> str: parsed = urlsplit(remote_url) - if parsed.username is None and parsed.password is None: - return remote_url if parsed.hostname is None: return remote_url netloc = parsed.hostname @@ -1293,8 +1353,8 @@ def _redact_git_remote_url(remote_url: str) -> str: parsed.scheme, netloc, parsed.path, - parsed.query, - parsed.fragment, + "", + "", ) ) @@ -1385,9 +1445,14 @@ def _parse_git_name_status( status_and_path = tokens[index] index += 1 status_parts = status_and_path.split("\t", 1) - if len(status_parts) != 2: - raise ValueError("git diff name-status output is malformed") - status_token, first_path = status_parts + if len(status_parts) == 2: + status_token, first_path = status_parts + else: + status_token = status_and_path + if index >= len(tokens): + raise ValueError("git diff name-status output is malformed") + first_path = tokens[index] + index += 1 status_code = status_token[:1] old_path: str | None = None if status_code in {"R", "C"}: @@ -1493,6 +1558,7 @@ def _docker_provider_ready(provider_config: _DockerWorkspaceProviderConfig) -> b def _clone_git_workspace_source( provider_config: _DockerWorkspaceProviderConfig, + config: dict[str, object], source: CloudWorkspaceSource, workspace_root: Path, ) -> None: @@ -1510,6 +1576,7 @@ def _clone_git_workspace_source( 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") + _validate_git_source_remote_url(config, remote_url) clone_command = [ provider_config.git_binary, @@ -1560,6 +1627,49 @@ def _clone_git_workspace_source( raise +def _validate_git_source_remote_url(config: dict[str, object], remote_url: str) -> None: + parsed = urlsplit(remote_url) + if parsed.scheme not in {"https", "git+https"}: + raise ValueError("workspace_source.remote_url must use https") + if parsed.hostname is None: + raise ValueError("workspace_source.remote_url must include a host") + if parsed.username is not None or parsed.password is not None: + raise ValueError("workspace_source.remote_url must not include credentials") + if parsed.query or parsed.fragment: + raise ValueError( + "workspace_source.remote_url must not include query or fragment" + ) + + remote_sources = config.get("remote_sources") + git_sources = ( + remote_sources.get("git") if isinstance(remote_sources, dict) else None + ) + if not isinstance(git_sources, dict): + raise ValueError("remote_sources.git.allowed_hosts must be configured") + allowed_hosts_raw = git_sources.get("allowed_hosts") + allowed_hosts = _remote_source_string_list( + allowed_hosts_raw, + key="allowed_hosts", + ) + if not allowed_hosts: + raise ValueError("remote_sources.git.allowed_hosts must be configured") + if parsed.hostname.lower() not in allowed_hosts: + raise ValueError( + "workspace_source.remote_url host is not in remote_sources.git.allowed_hosts" + ) + + +def _remote_source_string_list(value: object, *, key: str) -> tuple[str, ...]: + if not isinstance(value, list): + raise ValueError(f"remote_sources.git.{key} must be a list of strings") + result: list[str] = [] + for item in value: + if not isinstance(item, str) or not item.strip(): + raise ValueError(f"remote_sources.git.{key} must be a list of strings") + result.append(item.strip().lower()) + return tuple(result) + + def _add_git_failure_output_notes( exc: BaseException, operation: str, diff --git a/coding-agent/src/coding_agent/ui/http_server.py b/coding-agent/src/coding_agent/ui/http_server.py index f70566301..c0c7788b6 100644 --- a/coding-agent/src/coding_agent/ui/http_server.py +++ b/coding-agent/src/coding_agent/ui/http_server.py @@ -168,6 +168,10 @@ def _load_cloud_workspace_config() -> dict[str, Any]: if runtime_profiles: cloud_workspace_config = dict(cloud_workspace_config) cloud_workspace_config["runtime_profiles"] = runtime_profiles + remote_sources = _load_remote_sources_config() + if remote_sources: + cloud_workspace_config = dict(cloud_workspace_config) + cloud_workspace_config["remote_sources"] = remote_sources remote_phases = _load_remote_phases_config() if remote_phases: cloud_workspace_config = dict(cloud_workspace_config) @@ -183,6 +187,10 @@ def _load_remote_publication_config() -> dict[str, Any]: return _load_agent_config_section("remote_publication") +def _load_remote_sources_config() -> dict[str, Any]: + return _load_agent_config_section("remote_sources") + + def _load_remote_phases_config() -> dict[str, Any]: return _load_agent_config_section("remote_phases") 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 4af7b0d57..76161dd25 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 @@ -932,6 +932,9 @@ def fake_run( "workspace_root": str(workspace_root), "docker_binary": "/usr/bin/docker", "git_binary": "/usr/bin/git", + "remote_sources": { + "git": {"allowed_hosts": ["github.com"]}, + }, "remote_phases": { "setup": { "enabled": True, @@ -956,6 +959,90 @@ def fake_run( assert events == ["clone", "checkout", "setup", "agent"] +def test_docker_workspace_provider_rejects_unallowlisted_git_source_before_clone( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_root.mkdir() + subprocess_called = False + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + nonlocal subprocess_called + del command, kwargs + subprocess_called = True + raise AssertionError("git clone should not run") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises( + ValueError, match=r"host is not in remote_sources\.git\.allowed_hosts" + ): + _ = provision_cloud_binding_from_config( + _docker_config( + { + "workspace_root": str(workspace_root), + "docker_binary": "/usr/bin/docker", + "git_binary": "/usr/bin/git", + "remote_sources": { + "git": {"allowed_hosts": ["github.com"]}, + }, + } + ), + { + "kind": "git", + "remote_url": "https://internal.example.com/org/repo.git", + "base_ref": "main", + "base_sha": "abc123", + }, + ) + + assert subprocess_called is False + assert list(workspace_root.iterdir()) == [] + + +def test_docker_workspace_provider_rejects_unsafe_git_source_transport_before_clone( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_root.mkdir() + subprocess_called = False + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + nonlocal subprocess_called + del command, kwargs + subprocess_called = True + raise AssertionError("git clone should not run") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(ValueError, match="workspace_source.remote_url must use https"): + _ = provision_cloud_binding_from_config( + _docker_config( + { + "workspace_root": str(workspace_root), + "docker_binary": "/usr/bin/docker", + "git_binary": "/usr/bin/git", + "remote_sources": { + "git": {"allowed_hosts": ["github.com"]}, + }, + } + ), + { + "kind": "git", + "remote_url": "file:///private/repo.git", + "base_ref": "main", + "base_sha": "abc123", + }, + ) + + assert subprocess_called is False + assert list(workspace_root.iterdir()) == [] + + def test_docker_workspace_provider_rejects_invalid_git_base_sha_before_subprocess( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -982,6 +1069,9 @@ def fake_run( "workspace_root": str(workspace_root), "docker_binary": "/usr/bin/docker", "git_binary": "/usr/bin/git", + "remote_sources": { + "git": {"allowed_hosts": ["github.com"]}, + }, } ), { @@ -1022,6 +1112,9 @@ def fake_run( "workspace_root": str(workspace_root), "docker_binary": "/usr/bin/docker", "git_binary": "/usr/bin/git", + "remote_sources": { + "git": {"allowed_hosts": ["github.com"]}, + }, } ), { @@ -1050,7 +1143,9 @@ def fake_run( ) -> subprocess.CompletedProcess[str]: assert kwargs["stdin"] is subprocess.DEVNULL assert command[0:3] == ["git", "-C", str(workspace_dir)] - if command[3:6] == ["diff", "--name-status", "--find-renames"]: + if command[3:] in (["read-tree", "HEAD"], ["add", "-A"]): + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if command[3:7] == ["diff", "--cached", "--name-status", "--find-renames"]: return subprocess.CompletedProcess( command, 0, @@ -1063,7 +1158,7 @@ def fake_run( ), stderr="", ) - if command[3:5] == ["diff", "--numstat"]: + if command[3:6] == ["diff", "--cached", "--numstat"]: return subprocess.CompletedProcess( command, 0, @@ -1119,16 +1214,12 @@ def fake_run( command: list[str], **kwargs: object ) -> subprocess.CompletedProcess[str]: assert kwargs["stdin"] is subprocess.DEVNULL - assert command == [ - "git", - "-C", - str(workspace_dir), - "diff", - "--binary", - "HEAD", - "--", - ] - return subprocess.CompletedProcess(command, 0, stdout=patch_text, stderr="") + assert command[0:3] == ["git", "-C", str(workspace_dir)] + if command[3:] in (["read-tree", "HEAD"], ["add", "-A"]): + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if command[3:] == ["diff", "--cached", "--binary", "HEAD", "--"]: + return subprocess.CompletedProcess(command, 0, stdout=patch_text, stderr="") + raise AssertionError(f"unexpected command: {command}") monkeypatch.setattr(subprocess, "run", fake_run) @@ -1142,6 +1233,47 @@ def fake_run( assert patch.patch == patch_text +def test_docker_workspace_provider_diff_and_patch_include_untracked_files( + tmp_path: Path, +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + workspace_dir.mkdir(parents=True) + subprocess.run(["git", "init"], cwd=workspace_dir, check=True) + subprocess.run( + ["git", "config", "user.name", "Test Agent"], + cwd=workspace_dir, + check=True, + ) + subprocess.run( + ["git", "config", "user.email", "agent@example.com"], + cwd=workspace_dir, + check=True, + ) + (workspace_dir / "README.md").write_text("base\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=workspace_dir, check=True) + subprocess.run(["git", "commit", "-m", "base"], cwd=workspace_dir, check=True) + (workspace_dir / "new.txt").write_text("new file\n", encoding="utf-8") + + config = _docker_config({"workspace_root": str(workspace_root)}) + diff = workspace_diff_from_config(config, "ws-123") + patch = workspace_patch_from_config(config, "ws-123") + status = subprocess.run( + ["git", "status", "--porcelain=v1"], + cwd=workspace_dir, + check=True, + capture_output=True, + text=True, + ) + + assert [ + (file.path, file.status, file.additions, file.deletions) for file in diff.files + ] == [("new.txt", "added", 1, 0)] + assert "diff --git a/new.txt b/new.txt" in patch.patch + assert "+new file" in patch.patch + assert status.stdout == "?? new.txt\n" + + def test_docker_workspace_provider_publishes_git_workspace_branch( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -1184,7 +1316,7 @@ def fake_run( return subprocess.CompletedProcess( command, 0, - stdout="https://user:secret@github.com/org/repo.git\n", + stdout="https://user:secret@github.com/org/repo.git?token=secret#frag\n", stderr="", ) if args == ["push", "origin", "HEAD:refs/heads/coding-agent/test"]: @@ -1238,29 +1370,18 @@ def test_docker_workspace_provider_rejects_unsafe_git_remote_before_push( workspace_dir = workspace_root / "ws-123" (workspace_dir / ".git").mkdir(parents=True) + commands: list[list[str]] = [] + def fake_run( command: list[str], **kwargs: object ) -> subprocess.CompletedProcess[str]: del kwargs + commands.append(command) args = command[3:] if args == ["status", "--porcelain=v1", "-z"]: return subprocess.CompletedProcess(command, 0, stdout=" M README.md\0") if args == ["check-ref-format", "--branch", "coding-agent/test"]: return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == ["config", "user.name", "coding-agent"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == ["config", "user.email", "coding-agent@example.com"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == ["add", "-A"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == [ - "commit", - "-m", - "Apply coding-agent remote session sess-123 changes", - ]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == ["rev-parse", "HEAD"]: - return subprocess.CompletedProcess(command, 0, stdout="abc123\n", stderr="") if args == ["config", "--get", "remote.origin.url"]: return subprocess.CompletedProcess( command, @@ -1286,6 +1407,10 @@ def fake_run( "Apply coding-agent remote session sess-123 changes", ) + mutating_args = [command[3:] for command in commands] + assert ["add", "-A"] not in mutating_args + assert not any(args[:1] == ["commit"] for args in mutating_args) + def test_docker_workspace_provider_requires_git_remote_host_allowlist_before_push( monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -1294,29 +1419,18 @@ def test_docker_workspace_provider_requires_git_remote_host_allowlist_before_pus workspace_dir = workspace_root / "ws-123" (workspace_dir / ".git").mkdir(parents=True) + commands: list[list[str]] = [] + def fake_run( command: list[str], **kwargs: object ) -> subprocess.CompletedProcess[str]: del kwargs + commands.append(command) args = command[3:] if args == ["status", "--porcelain=v1", "-z"]: return subprocess.CompletedProcess(command, 0, stdout=" M README.md\0") if args == ["check-ref-format", "--branch", "coding-agent/test"]: return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == ["config", "user.name", "coding-agent"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == ["config", "user.email", "coding-agent@example.com"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == ["add", "-A"]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == [ - "commit", - "-m", - "Apply coding-agent remote session sess-123 changes", - ]: - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - if args == ["rev-parse", "HEAD"]: - return subprocess.CompletedProcess(command, 0, stdout="abc123\n", stderr="") if args == ["config", "--get", "remote.origin.url"]: return subprocess.CompletedProcess( command, @@ -1343,6 +1457,59 @@ def fake_run( "Apply coding-agent remote session sess-123 changes", ) + mutating_args = [command[3:] for command in commands] + assert ["add", "-A"] not in mutating_args + assert not any(args[:1] == ["commit"] for args in mutating_args) + + +def test_docker_workspace_provider_requires_git_token_before_commit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + (workspace_dir / ".git").mkdir(parents=True) + commands: list[list[str]] = [] + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + del kwargs + commands.append(command) + args = command[3:] + if args == ["status", "--porcelain=v1", "-z"]: + return subprocess.CompletedProcess(command, 0, stdout=" M README.md\0") + if args == ["check-ref-format", "--branch", "coding-agent/test"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["config", "--get", "remote.origin.url"]: + return subprocess.CompletedProcess( + command, + 0, + stdout="https://github.com/org/repo.git\n", + stderr="", + ) + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(ValueError, match="remote_publication.git_token_env is not set"): + _ = publish_workspace_branch_from_config( + _docker_config({"workspace_root": str(workspace_root)}), + { + "enabled": True, + "git_author_name": "coding-agent", + "git_author_email": "coding-agent@example.com", + "git_token_env": "MISSING_CODING_AGENT_GIT_TOKEN", + "allowed_git_hosts": ["github.com"], + }, + "ws-123", + "coding-agent/test", + "Apply coding-agent remote session sess-123 changes", + ) + + mutating_args = [command[3:] for command in commands] + assert ["add", "-A"] not in mutating_args + assert not any(args[:1] == ["commit"] for args in mutating_args) + def test_docker_workspace_provider_rejects_unallowlisted_git_remote_host_before_push( monkeypatch: pytest.MonkeyPatch, tmp_path: Path diff --git a/coding-agent/tests/ui/test_http_server.py b/coding-agent/tests/ui/test_http_server.py index ddcd49405..c5d48cc8e 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -1028,6 +1028,7 @@ async def test_create_session_provisions_git_workspace_source( workspace_id="ws-git", ) captured_sources: list[dict[str, object]] = [] + captured_configs: list[dict[str, object]] = [] monkeypatch.setattr( "coding_agent.ui.http_server._load_cloud_workspace_config", @@ -1037,11 +1038,16 @@ async def test_create_session_provisions_git_workspace_source( "workspace_root": str(tmp_path), "container_name_prefix": "agent-", **_test_runtime_profile_config(), + "remote_sources": {"git": {"allowed_hosts": ["github.com"]}}, }, ) monkeypatch.setattr( "coding_agent.ui.http_server.provision_cloud_binding_from_config", - lambda config, source: captured_sources.append(dict(source)) or binding, + lambda config, source: ( + captured_configs.append(dict(config)) + or captured_sources.append(dict(source)) + or binding + ), ) response = await client.post( @@ -1067,6 +1073,9 @@ async def test_create_session_provisions_git_workspace_source( "runtime_profile": "universal", } ] + assert captured_configs[0]["remote_sources"] == { + "git": {"allowed_hosts": ["github.com"]} + } session = session_manager.get_session(response.json()["session_id"]) assert session.execution_binding == binding assert session.origin == {