From 09f2044d9fdb47e61074c85e53a3e97a8dd2dbdd Mon Sep 17 00:00:00 2001 From: kina0630 Date: Wed, 13 May 2026 19:05:11 +0800 Subject: [PATCH 1/4] feat: publish remote result branches --- .../docs/remote-sandbox-production.md | 73 +++++-- coding-agent/src/coding_agent/__main__.py | 32 +++ .../src/coding_agent/environment/__init__.py | 4 + .../environment/docker_workspace_provider.py | 198 +++++++++++++++++- .../environment/workspace_provider.py | 35 ++++ .../src/coding_agent/remote/client.py | 22 +- .../src/coding_agent/ui/http_server.py | 49 ++++- coding-agent/src/coding_agent/ui/schemas.py | 1 + coding-agent/tests/cli/test_remote_client.py | 77 +++++++ .../test_docker_workspace_provider.py | 87 ++++++++ coding-agent/tests/ui/test_http_server.py | 111 +++++++++- 11 files changed, 661 insertions(+), 28 deletions(-) diff --git a/coding-agent/docs/remote-sandbox-production.md b/coding-agent/docs/remote-sandbox-production.md index ebc13680d..379c19242 100644 --- a/coding-agent/docs/remote-sandbox-production.md +++ b/coding-agent/docs/remote-sandbox-production.md @@ -77,6 +77,12 @@ allow_request_commands = false network = "none" timeout_seconds = 3600 secret_env_allowlist = [] + +[remote_publication] +enabled = true +git_author_name = "coding-agent" +git_author_email = "coding-agent@example.com" +git_token_env = "CODING_AGENT_GIT_TOKEN" ``` `python:3.11-slim` is enough for basic file operations and pure-Python tools, @@ -197,17 +203,15 @@ Use `remote run` for the recommended one-shot remote workflow: coding-agent remote run team --repo . --goal "fix the failing test" ``` -`remote run --repo` uploads a local snapshot, streams one prompt, downloads the -final workspace through the archive manifest/archive API, overwrites the local -checkout while preserving `.git`, and then closes the remote session. Use -`--yes` only when the local checkout is already recoverable and you want to skip -the manifest confirmation prompt; it does not approve tools. +`remote run --repo` uploads a local snapshot, streams one prompt, leaves the +remote session open, and prints follow-up commands for inspecting or exporting +the result. It does not overwrite the local checkout by default. Set the remote tool approval policy explicitly when automation cannot answer approval prompts: ```bash -coding-agent remote run team --repo . --goal "fix the failing test" --approval yolo --yes +coding-agent remote run team --repo . --goal "fix the failing test" --approval yolo ``` Select a configured runtime profile when the project needs more tooling than the @@ -225,6 +229,35 @@ repeatable bootstrap behavior. Request-provided setup commands remain reserved for the later setup execution slice and currently fail closed instead of being accepted as a silent no-op. +For Git-backed sessions, publish remote changes to a review branch explicitly: + +```bash +coding-agent remote publish team --session --branch coding-agent/session- +``` + +Branch publication requires `[remote_publication]` to be enabled, Git author +identity to be configured, and server-side Git credentials. 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 +creation is not part of this slice. + +Snapshot sessions can still be inspected and exported, but they are not branch +publication inputs because snapshots intentionally exclude `.git`. + +Use `--download` only when you want the old archive-overwrite fallback: + +```bash +coding-agent remote run team --repo . --goal "fix the failing test" --download +``` + +`--download` fetches the final workspace through the archive manifest/archive +API, overwrites the local checkout while preserving `.git`, and then closes the +remote session. Use `--yes` with `--download` only when the local checkout is +already recoverable and you want to skip the manifest confirmation prompt; it +does not approve tools. + `--approval auto` is the default and can still ask for approval for tools such as `bash_run`. `--approval interactive` asks for every tool. `--approval yolo` lets the server approve tool calls for that one remote session, so use it only @@ -242,6 +275,9 @@ coding-agent remote sessions status team coding-agent remote sessions cancel team coding-agent remote sessions close team +coding-agent remote diff team --session +coding-agent remote patch team --session +coding-agent remote publish team --session --branch coding-agent/session- coding-agent remote download team --session --repo . coding-agent remote workspaces list team-admin @@ -279,20 +315,20 @@ providers, or microVM providers. ## Snapshot Transfer Semantics -Remote runs use snapshot round-trip transfer: +Snapshot fallback runs use archive round-trip transfer: 1. The local repo is packed as a bounded `tar.gz` archive encoded as base64. 2. The server extracts that archive into a Docker workspace. 3. The agent executes in the remote workspace. -4. The final remote workspace is downloaded. -5. The local checkout is overwritten while local `.git` is preserved. +4. The final remote workspace remains remote unless explicitly downloaded. +5. Explicit archive download overwrites the local checkout while local `.git` + is preserved. -This is not live sync. P0 does not support incremental patch export, concurrent -local edit merging, efficient large-repo delta sync, or automatic conflict -resolution. +This is not live sync. P0 does not support concurrent local edit merging, +efficient large-repo delta sync, or automatic conflict resolution. -Before using `remote run --repo` or `remote repl --repo`, keep your local work -recoverable with a commit, stash, or backup. +Before using `remote download`, `remote run --download`, or `remote repl +--repo`, keep your local work recoverable with a commit, stash, or backup. ## Production Smoke @@ -310,8 +346,9 @@ provider: ``` 3. Run a small repository through `remote run --repo`. -4. Confirm the downloaded workspace contains the remote change. -5. Confirm successful one-shot runs leave no active sessions or workspaces: +4. Inspect the remote result with `remote diff` and `remote patch`. +5. Export the result explicitly with `remote download` or close the session. +6. Confirm closed sessions leave no active workspaces: ```bash coding-agent remote sessions list team @@ -319,5 +356,5 @@ provider: ``` This verifies production config loading, auth, Docker provider readiness, -workspace execution, approval forwarding, archive manifest/download, local -restore, and session/workspace cleanup. +workspace execution, approval forwarding, result inspection, explicit archive +download, and session/workspace cleanup. diff --git a/coding-agent/src/coding_agent/__main__.py b/coding-agent/src/coding_agent/__main__.py index 13da680e8..7f6812764 100644 --- a/coding-agent/src/coding_agent/__main__.py +++ b/coding-agent/src/coding_agent/__main__.py @@ -1059,6 +1059,38 @@ def remote_patch(name: str, session_id: str) -> None: click.echo(patch, nl=False) +@remote.command("publish") +@click.argument("name") +@click.option("--session", "session_id", required=True, help="Remote session ID") +@click.option( + "--branch", + "branch_name", + required=True, + help="Publish the remote session changes to this Git branch.", +) +def remote_publish(name: str, session_id: str, branch_name: str) -> None: + """Publish remote session results as a Git branch.""" + from coding_agent.remote.client import get_remote, publish_remote_branch + + endpoint = get_remote(name) + publication = publish_remote_branch(endpoint, session_id, branch_name) + published_branch = publication.get("branch_name") + pushed_ref = publication.get("pushed_ref") + commit_sha = publication.get("commit_sha") + remote_url = publication.get("remote_url") + if not isinstance(published_branch, str) or not published_branch: + raise click.ClickException("Remote publish response missing branch_name") + if not isinstance(commit_sha, str) or not commit_sha: + raise click.ClickException("Remote publish response missing commit_sha") + if not isinstance(remote_url, str) or not remote_url: + raise click.ClickException("Remote publish response missing remote_url") + click.echo(f"Published branch {published_branch}") + if isinstance(pushed_ref, str) and pushed_ref: + click.echo(f"Ref: {pushed_ref}") + click.echo(f"Commit: {commit_sha}") + click.echo(f"Remote: {remote_url}") + + @remote.command("download") @click.argument("name") @click.option("--session", "session_id", required=True, help="Remote session ID") diff --git a/coding-agent/src/coding_agent/environment/__init__.py b/coding-agent/src/coding_agent/environment/__init__.py index 38a47cb7a..c8be8633d 100644 --- a/coding-agent/src/coding_agent/environment/__init__.py +++ b/coding-agent/src/coding_agent/environment/__init__.py @@ -12,6 +12,7 @@ CloudWorkspaceClientFactory, CloudWorkspaceSource, WorkspaceArchiveManifest, + WorkspaceBranchPublication, WorkspaceDiff, WorkspaceDiffFile, WorkspaceInventoryEntry, @@ -24,6 +25,7 @@ get_cloud_workspace_from_config, import_workspace_archive_from_config, list_cloud_workspaces_from_config, + publish_workspace_branch_from_config, provision_cloud_binding_from_config, register_workspace_provider, workspace_archive_manifest_from_config, @@ -43,6 +45,7 @@ "cleanup_stale_cloud_workspaces_from_config", "LocalEnvironment", "WorkspaceArchiveManifest", + "WorkspaceBranchPublication", "WorkspaceDiff", "WorkspaceDiffFile", "WorkspaceInventoryEntry", @@ -55,6 +58,7 @@ "get_cloud_workspace_from_config", "import_workspace_archive_from_config", "list_cloud_workspaces_from_config", + "publish_workspace_branch_from_config", "provision_cloud_binding_from_config", "register_workspace_provider", "workspace_archive_manifest_from_config", 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 91be5ee9c..de8431d91 100644 --- a/coding-agent/src/coding_agent/environment/docker_workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/docker_workspace_provider.py @@ -18,6 +18,7 @@ from pathlib import Path import os from typing import TYPE_CHECKING, cast, override +from urllib.parse import urlsplit, urlunsplit from .cloud import CloudCommandResult from .workspace_provider import ( @@ -25,6 +26,7 @@ CloudWorkspaceSource, WorkspaceProvider, WorkspaceArchiveManifest, + WorkspaceBranchPublication, WorkspaceDiff, WorkspaceDiffFile, WorkspaceDiffStatus, @@ -669,6 +671,32 @@ def workspace_patch( patch=patch, ) + @override + def publish_workspace_branch( + self, + config: dict[str, object], + publication_config: dict[str, object], + workspace_id: str, + branch_name: str, + commit_message: str, + ) -> WorkspaceBranchPublication: + if publication_config.get("enabled") is not True: + raise ValueError("remote_publication.enabled must be true") + provider_config = _docker_workspace_provider_config(config) + workspace_root = _git_workspace_root_for_operation( + provider_config, + workspace_id, + "publication", + ) + return _publish_git_workspace_branch( + provider_config, + publication_config, + workspace_id, + workspace_root, + branch_name, + commit_message, + ) + def _docker_workspace_provider_config( config: dict[str, object], @@ -1116,11 +1144,179 @@ def _git_workspace_diff( ) +def _publish_git_workspace_branch( + provider_config: _DockerWorkspaceProviderConfig, + publication_config: dict[str, object], + workspace_id: str, + workspace_root: Path, + branch_name: str, + commit_message: str, +) -> WorkspaceBranchPublication: + author_name = _required_publication_string( + publication_config, + "git_author_name", + ) + author_email = _required_publication_string( + publication_config, + "git_author_email", + ) + branch_name = branch_name.strip() + if not branch_name: + raise ValueError("branch_name must be non-empty") + commit_message = commit_message.strip() + if not commit_message: + raise ValueError("commit_message must be non-empty") + + status = _run_git_publish_command( + provider_config, + workspace_root, + ["status", "--porcelain=v1", "-z"], + "git status", + ) + if not status: + raise ValueError("workspace publication requires uncommitted changes") + + _run_git_publish_command( + provider_config, + workspace_root, + ["check-ref-format", "--branch", branch_name], + "git check-ref-format", + ) + _run_git_publish_command( + provider_config, + workspace_root, + ["config", "user.name", author_name], + "git config user.name", + ) + _run_git_publish_command( + provider_config, + workspace_root, + ["config", "user.email", author_email], + "git config user.email", + ) + _run_git_publish_command( + provider_config, + workspace_root, + ["add", "-A"], + "git add", + ) + _run_git_publish_command( + provider_config, + workspace_root, + ["commit", "-m", commit_message], + "git commit", + ) + commit_sha = _run_git_publish_command( + provider_config, + workspace_root, + ["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() + if not remote_url: + raise ValueError("Git workspace has no remote.origin.url") + pushed_ref = f"refs/heads/{branch_name}" + push_env = _git_publication_push_env(publication_config) + _run_git_publish_command( + provider_config, + workspace_root, + ["push", "origin", f"HEAD:{pushed_ref}"], + "git push", + extra_env=push_env, + ) + return WorkspaceBranchPublication( + workspace_id=workspace_id, + branch_name=branch_name, + pushed_ref=pushed_ref, + commit_sha=commit_sha, + remote_url=_redact_git_remote_url(remote_url), + ) + + +def _run_git_publish_command( + provider_config: _DockerWorkspaceProviderConfig, + workspace_root: Path, + args: list[str], + operation: str, + *, + extra_env: dict[str, str] | None = None, +) -> str: + try: + return _run_git_workspace_command( + provider_config, + workspace_root, + args, + operation, + extra_env=extra_env, + ) + except subprocess.CalledProcessError as exc: + error = ValueError(f"{operation} failed") + for note in getattr(exc, "__notes__", ()): + error.add_note(note) + raise error from exc + + +def _git_publication_push_env( + publication_config: dict[str, object], +) -> dict[str, str]: + token_env = _optional_string(publication_config.get("git_token_env"), None) + if token_env is None: + return {} + token = os.environ.get(token_env) + if token is None or not token.strip(): + raise ValueError(f"remote_publication.git_token_env is not set: {token_env}") + credential = base64.b64encode( + f"x-access-token:{token.strip()}".encode("utf-8") + ).decode("ascii") + return { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraheader", + "GIT_CONFIG_VALUE_0": f"Authorization: Basic {credential}", + } + + +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 + if parsed.port is not None: + netloc = f"{netloc}:{parsed.port}" + return urlunsplit( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.query, + parsed.fragment, + ) + ) + + +def _required_publication_string( + config: dict[str, object], + key: str, +) -> str: + value = config.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"remote_publication.{key} must be configured") + return value.strip() + + def _run_git_workspace_command( provider_config: _DockerWorkspaceProviderConfig, workspace_root: Path, args: list[str], operation: str, + *, + extra_env: dict[str, str] | None = None, ) -> str: command = [ provider_config.git_binary, @@ -1136,7 +1332,7 @@ def _run_git_workspace_command( text=True, timeout=_GIT_OPERATION_TIMEOUT_SECONDS, stdin=subprocess.DEVNULL, - env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0", **(extra_env or {})}, check=True, ) except subprocess.CalledProcessError as exc: diff --git a/coding-agent/src/coding_agent/environment/workspace_provider.py b/coding-agent/src/coding_agent/environment/workspace_provider.py index 001d0022b..9c60a8ffa 100644 --- a/coding-agent/src/coding_agent/environment/workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/workspace_provider.py @@ -69,6 +69,15 @@ class WorkspacePatch: patch: str +@dataclass(frozen=True) +class WorkspaceBranchPublication: + workspace_id: str + branch_name: str + pushed_ref: str + commit_sha: str + remote_url: str + + class WorkspaceProvider(Protocol): def build_cloud_client_factory( self, config: dict[str, object] @@ -155,6 +164,15 @@ def workspace_patch( workspace_id: str, ) -> WorkspacePatch: ... + def publish_workspace_branch( + self, + config: dict[str, object], + publication_config: dict[str, object], + workspace_id: str, + branch_name: str, + commit_message: str, + ) -> WorkspaceBranchPublication: ... + _WORKSPACE_PROVIDERS: dict[str, WorkspaceProvider] = {} @@ -294,6 +312,23 @@ def workspace_patch_from_config( return provider.workspace_patch(config, workspace_id) +def publish_workspace_branch_from_config( + config: dict[str, object], + publication_config: dict[str, object], + workspace_id: str, + branch_name: str, + commit_message: str, +) -> WorkspaceBranchPublication: + provider = _provider_from_config(config) + return provider.publish_workspace_branch( + config, + publication_config, + workspace_id, + branch_name, + commit_message, + ) + + def _provider_from_config(config: dict[str, object]) -> WorkspaceProvider: provider_name = config.get("provider") if not isinstance(provider_name, str) or not provider_name.strip(): diff --git a/coding-agent/src/coding_agent/remote/client.py b/coding-agent/src/coding_agent/remote/client.py index b02f75b56..1fcf74403 100644 --- a/coding-agent/src/coding_agent/remote/client.py +++ b/coding-agent/src/coding_agent/remote/client.py @@ -302,6 +302,22 @@ def download_workspace_patch(endpoint: RemoteEndpoint, session_id: str) -> str: return patch +def publish_remote_branch( + endpoint: RemoteEndpoint, + session_id: str, + branch_name: str, +) -> dict[str, object]: + data = _post_remote_json( + endpoint, + f"/sessions/{session_id}/publish", + "publish remote workspace branch", + json={"mode": "branch", "branch_name": branch_name}, + ) + if data.get("status") != "published": + raise click.ClickException("Remote branch publication did not complete") + return dict(data) + + def _get_remote_json( endpoint: RemoteEndpoint, path: str, @@ -325,6 +341,8 @@ def _post_remote_json( endpoint: RemoteEndpoint, path: str, action: str, + *, + json: dict[str, object] | None = None, ) -> dict[str, object]: try: with httpx.Client( @@ -332,7 +350,9 @@ def _post_remote_json( headers=auth_headers(endpoint), timeout=30.0, ) as client: - response = client.post(path) + response = ( + client.post(path, json=json) if json is not None else client.post(path) + ) _raise_remote_http_error(response, action) data = cast(object, response.json()) except httpx.RequestError as exc: diff --git a/coding-agent/src/coding_agent/ui/http_server.py b/coding-agent/src/coding_agent/ui/http_server.py index 5f6b44c6e..13b05b3e8 100644 --- a/coding-agent/src/coding_agent/ui/http_server.py +++ b/coding-agent/src/coding_agent/ui/http_server.py @@ -33,6 +33,7 @@ export_workspace_archive_from_config, get_cloud_workspace_from_config, list_cloud_workspaces_from_config, + publish_workspace_branch_from_config, provision_cloud_binding_from_config, workspace_archive_manifest_from_config, workspace_diff_from_config, @@ -163,6 +164,10 @@ def _load_runtime_profiles_config() -> dict[str, Any]: return _load_agent_config_section("runtime_profiles") +def _load_remote_publication_config() -> dict[str, Any]: + return _load_agent_config_section("remote_publication") + + def _load_remote_phases_config() -> dict[str, Any]: return _load_agent_config_section("remote_phases") @@ -1828,11 +1833,47 @@ async def publish_session_result( body: PublishSessionRequest, auth_context: AuthContext | None = Depends(auth_context_from_headers), ) -> PublishSessionResponse: - del request, body + del request _ = await _get_visible_session(session_id, auth_context) - raise HTTPException( - status_code=501, - detail="remote result publication is not implemented yet", + if body.mode == "pr": + raise HTTPException( + status_code=501, + detail="remote PR publication is not implemented yet", + ) + branch_name = body.branch_name or f"coding-agent/session-{session_id}" + commit_message = f"Apply coding-agent remote session {session_id} changes" + try: + publication = await session_manager.export_workspace_archive( + session_id, + lambda binding: publish_workspace_branch_from_config( + _load_cloud_workspace_config(), + _load_remote_publication_config(), + binding.workspace_id, + branch_name, + commit_message, + ), + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=_key_error_detail(exc)) from exc + except SessionOwnershipConflictError as exc: + raise _owner_conflict_http_exception(exc, session_id=session_id) from exc + except (ValueError, TypeError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + if str(exc) == "turn already in progress": + raise HTTPException(status_code=409, detail=str(exc)) from exc + raise + + return PublishSessionResponse( + session_id=session_id, + mode="branch", + status="published", + branch_name=publication.branch_name, + pushed_ref=publication.pushed_ref, + commit_sha=publication.commit_sha, + remote_url=publication.remote_url, + pr_url=None, + error=None, ) diff --git a/coding-agent/src/coding_agent/ui/schemas.py b/coding-agent/src/coding_agent/ui/schemas.py index 78bcc0571..61774a3f4 100644 --- a/coding-agent/src/coding_agent/ui/schemas.py +++ b/coding-agent/src/coding_agent/ui/schemas.py @@ -181,6 +181,7 @@ class PublishSessionResponse(BaseModel): mode: Literal["branch", "pr"] status: Literal["published", "unsupported", "failed"] branch_name: str | None = None + pushed_ref: str | None = None commit_sha: str | None = None remote_url: str | None = None pr_url: str | None = None diff --git a/coding-agent/tests/cli/test_remote_client.py b/coding-agent/tests/cli/test_remote_client.py index e51946af7..d2f4a7f62 100644 --- a/coding-agent/tests/cli/test_remote_client.py +++ b/coding-agent/tests/cli/test_remote_client.py @@ -1070,6 +1070,83 @@ def get(self, path: str) -> FakeResponse: ] +def test_remote_publish_branch_prints_publication_result( + tmp_path: Path, monkeypatch +) -> None: + config_path = tmp_path / "remotes.json" + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example", "--token", "secret-token"], + catch_exceptions=False, + ) + calls: list[tuple[str, dict[str, object] | None, dict[str, str]]] = [] + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return { + "session_id": "sess-123", + "mode": "branch", + "status": "published", + "branch_name": "coding-agent/result", + "pushed_ref": "refs/heads/coding-agent/result", + "commit_sha": "abc123", + "remote_url": "https://github.com/org/repo.git", + "pr_url": None, + "error": None, + } + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + headers = kwargs.get("headers") + self.headers = headers if isinstance(headers, dict) else {} + + def __enter__(self) -> FakeClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def post( + self, path: str, json: dict[str, object] | None = None + ) -> FakeResponse: + calls.append((path, json, self.headers)) + return FakeResponse() + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + + result = runner.invoke( + main, + [ + "remote", + "publish", + "dev", + "--session", + "sess-123", + "--branch", + "coding-agent/result", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "Published branch coding-agent/result" in result.output + assert "Ref: refs/heads/coding-agent/result" in result.output + assert "Commit: abc123" in result.output + assert "Remote: https://github.com/org/repo.git" in result.output + assert calls == [ + ( + "/sessions/sess-123/publish", + {"mode": "branch", "branch_name": "coding-agent/result"}, + {"Authorization": "Bearer secret-token"}, + ) + ] + + def test_remote_repl_with_repo_uploads_snapshot_and_downloads_workspace( tmp_path: Path, monkeypatch ) -> None: 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 d5b1af528..fa27d950c 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 @@ -15,6 +15,7 @@ cleanup_stale_cloud_workspaces_from_config, cloud_client_factory_from_config, cloud_workspace_ready_from_config, + publish_workspace_branch_from_config, workspace_diff_from_config, workspace_patch_from_config, provision_cloud_binding_from_config, @@ -1141,6 +1142,92 @@ def fake_run( assert patch.patch == patch_text +def test_docker_workspace_provider_publishes_git_workspace_branch( + 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]] = [] + push_env: dict[str, str] | None = None + monkeypatch.setenv("CODING_AGENT_GIT_TOKEN", "secret-token") + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + nonlocal push_env + assert kwargs["stdin"] is subprocess.DEVNULL + commands.append(command) + assert command[0:3] == ["git", "-C", str(workspace_dir)] + 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="[detached HEAD abc123] msg\n", 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, + 0, + stdout="https://user:secret@github.com/org/repo.git\n", + stderr="", + ) + if args == ["push", "origin", "HEAD:refs/heads/coding-agent/test"]: + env = kwargs["env"] + assert isinstance(env, dict) + push_env = {key: env[key] for key in env if key.startswith("GIT_CONFIG_")} + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(subprocess, "run", fake_run) + + publication = 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": "CODING_AGENT_GIT_TOKEN", + }, + "ws-123", + "coding-agent/test", + "Apply coding-agent remote session sess-123 changes", + ) + + assert publication.workspace_id == "ws-123" + assert publication.branch_name == "coding-agent/test" + assert publication.pushed_ref == "refs/heads/coding-agent/test" + assert publication.commit_sha == "abc123" + assert publication.remote_url == "https://github.com/org/repo.git" + assert push_env is not None + assert push_env["GIT_CONFIG_COUNT"] == "1" + assert push_env["GIT_CONFIG_KEY_0"] == "http.extraheader" + assert "secret-token" not in push_env["GIT_CONFIG_VALUE_0"] + assert commands[-1] == [ + "git", + "-C", + str(workspace_dir), + "push", + "origin", + "HEAD:refs/heads/coding-agent/test", + ] + + def test_docker_workspace_provider_rejects_patch_for_snapshot_workspace_without_git( 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 3b2b7a8e0..ad134c46a 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -31,6 +31,7 @@ from coding_agent.core.config import settings from coding_agent.environment import CloudCommandResult, CloudEnvironment from coding_agent.environment.workspace_provider import ( + WorkspaceBranchPublication, WorkspaceDiff, WorkspaceDiffFile, WorkspacePatch, @@ -3610,7 +3611,79 @@ def fake_workspace_patch( "patch": "diff --git a/README.md b/README.md\n", } - async def test_publish_operation_fails_closed( + async def test_publish_branch_returns_provider_result( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + create_resp = await client.post( + "/sessions", + json={ + "execution_binding": { + "kind": "cloud", + "workspace_url": "docker://agent-ws-publish/workspace", + "workspace_id": "ws-publish", + }, + }, + ) + session_id = create_resp.json()["session_id"] + + def fake_publish( + cloud_config: dict[str, object], + publication_config: dict[str, object], + workspace_id: str, + branch_name: str, + commit_message: str, + ) -> WorkspaceBranchPublication: + assert cloud_config["provider"] == "docker" + assert publication_config["git_author_name"] == "coding-agent" + assert workspace_id == "ws-publish" + assert branch_name == "coding-agent/result" + assert commit_message == ( + f"Apply coding-agent remote session {session_id} changes" + ) + return WorkspaceBranchPublication( + workspace_id=workspace_id, + branch_name=branch_name, + pushed_ref="refs/heads/coding-agent/result", + commit_sha="abc123", + remote_url="https://github.com/org/repo.git", + ) + + monkeypatch.setattr( + "coding_agent.ui.http_server.publish_workspace_branch_from_config", + fake_publish, + ) + monkeypatch.setattr( + "coding_agent.ui.http_server._load_cloud_workspace_config", + lambda: {"provider": "docker"}, + ) + monkeypatch.setattr( + "coding_agent.ui.http_server._load_remote_publication_config", + lambda: { + "enabled": True, + "git_author_name": "coding-agent", + "git_author_email": "coding-agent@example.com", + }, + ) + + response = await client.post( + f"/sessions/{session_id}/publish", + json={"mode": "branch", "branch_name": "coding-agent/result"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "session_id": session_id, + "mode": "branch", + "status": "published", + "branch_name": "coding-agent/result", + "pushed_ref": "refs/heads/coding-agent/result", + "commit_sha": "abc123", + "remote_url": "https://github.com/org/repo.git", + "pr_url": None, + "error": None, + } + + async def test_publish_pr_fails_closed( self, client: AsyncClient, ) -> None: @@ -3619,15 +3692,45 @@ async def test_publish_operation_fails_closed( response = await client.post( f"/sessions/{session_id}/publish", - json={"mode": "branch"}, + json={"mode": "pr"}, ) assert response.status_code == 501 assert ( - response.json()["detail"] - == "remote result publication is not implemented yet" + response.json()["detail"] == "remote PR publication is not implemented yet" ) + async def test_publish_branch_requires_publication_config( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + create_resp = await client.post( + "/sessions", + json={ + "execution_binding": { + "kind": "cloud", + "workspace_url": "docker://agent-ws-publish/workspace", + "workspace_id": "ws-publish", + }, + }, + ) + session_id = create_resp.json()["session_id"] + monkeypatch.setattr( + "coding_agent.ui.http_server._load_cloud_workspace_config", + lambda: {"provider": "docker"}, + ) + monkeypatch.setattr( + "coding_agent.ui.http_server._load_remote_publication_config", + lambda: {}, + ) + + response = await client.post( + f"/sessions/{session_id}/publish", + json={"mode": "branch", "branch_name": "coding-agent/result"}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "remote_publication.enabled must be true" + async def test_remote_result_publication_operations_return_404_for_missing_session( self, client: AsyncClient ) -> None: From dd5856e4ac3c56ffb548244eee6b9d913b62e562 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Wed, 13 May 2026 19:21:45 +0800 Subject: [PATCH 2/4] fix: harden remote branch publication --- .../docs/remote-sandbox-production.md | 1 + .../environment/docker_workspace_provider.py | 24 +++++++- .../test_docker_workspace_provider.py | 55 +++++++++++++++++++ coding-agent/tests/ui/test_http_server.py | 39 ++++++++++++- 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/coding-agent/docs/remote-sandbox-production.md b/coding-agent/docs/remote-sandbox-production.md index 379c19242..c04f93468 100644 --- a/coding-agent/docs/remote-sandbox-production.md +++ b/coding-agent/docs/remote-sandbox-production.md @@ -154,6 +154,7 @@ Run the service on loopback and place TLS/authenticated ingress in front of it: User=coding-agent Group=coding-agent Environment=CODING_AGENT_BEARER_TOKEN=replace-with-secret +Environment=CODING_AGENT_GIT_TOKEN=replace-with-secret ExecStart=/usr/local/bin/coding-agent serve --config /etc/coding-agent/config.toml Restart=on-failure ``` 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 de8431d91..d9066454f 100644 --- a/coding-agent/src/coding_agent/environment/docker_workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/docker_workspace_provider.py @@ -1218,8 +1218,7 @@ def _publish_git_workspace_branch( ["config", "--get", "remote.origin.url"], "git config remote.origin.url", ).strip() - if not remote_url: - raise ValueError("Git workspace has no remote.origin.url") + _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( @@ -1300,6 +1299,27 @@ def _redact_git_remote_url(remote_url: str) -> str: ) +def _validate_publication_remote_url( + publication_config: dict[str, object], + remote_url: str, +) -> None: + if not remote_url: + raise ValueError("Git workspace has no remote.origin.url") + parsed = urlsplit(remote_url) + if parsed.scheme not in {"https", "git+https"}: + raise ValueError("Git workspace remote.origin.url must use https") + if parsed.hostname is None: + raise ValueError("Git workspace remote.origin.url must include a host") + allowed_hosts = _string_list( + publication_config.get("allowed_git_hosts"), + key="remote_publication.allowed_git_hosts", + ) + if allowed_hosts and parsed.hostname not in allowed_hosts: + raise ValueError( + "Git workspace remote.origin.url host is not in remote_publication.allowed_git_hosts" + ) + + def _required_publication_string( config: dict[str, object], key: str, 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 fa27d950c..9c4b71699 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 @@ -1228,6 +1228,61 @@ def fake_run( ] +def test_docker_workspace_provider_rejects_unsafe_git_remote_before_push( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + (workspace_dir / ".git").mkdir(parents=True) + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + del kwargs + 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, + 0, + stdout="http://insecure.example.com/org/repo.git\n", + stderr="", + ) + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(ValueError, match="must use https"): + _ = 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", + }, + "ws-123", + "coding-agent/test", + "Apply coding-agent remote session sess-123 changes", + ) + + def test_docker_workspace_provider_rejects_patch_for_snapshot_workspace_without_git( 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 ad134c46a..13cd01fca 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any from typing import cast -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from httpx import AsyncClient, ASGITransport @@ -3648,9 +3648,10 @@ def fake_publish( remote_url="https://github.com/org/repo.git", ) + mock_publish = MagicMock(side_effect=fake_publish) monkeypatch.setattr( "coding_agent.ui.http_server.publish_workspace_branch_from_config", - fake_publish, + mock_publish, ) monkeypatch.setattr( "coding_agent.ui.http_server._load_cloud_workspace_config", @@ -3671,6 +3672,17 @@ def fake_publish( ) assert response.status_code == 200 + mock_publish.assert_called_once_with( + {"provider": "docker"}, + { + "enabled": True, + "git_author_name": "coding-agent", + "git_author_email": "coding-agent@example.com", + }, + "ws-publish", + "coding-agent/result", + f"Apply coding-agent remote session {session_id} changes", + ) assert response.json() == { "session_id": session_id, "mode": "branch", @@ -3731,6 +3743,29 @@ async def test_publish_branch_requires_publication_config( assert response.status_code == 400 assert response.json()["detail"] == "remote_publication.enabled must be true" + async def test_publish_hides_other_user_session( + self, client: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv( + "CODING_AGENT_SERVER_CONFIG", str(_write_auth_config(tmp_path)) + ) + monkeypatch.setattr(settings, "http_api_key", None) + + created = await client.post( + "/sessions", + headers={"Authorization": "Bearer admin-token"}, + json={}, + ) + session_id = created.json()["session_id"] + + response = await client.post( + f"/sessions/{session_id}/publish", + headers={"Authorization": "Bearer user-token-a"}, + json={"mode": "branch", "branch_name": "test"}, + ) + + assert response.status_code == 404 + async def test_remote_result_publication_operations_return_404_for_missing_session( self, client: AsyncClient ) -> None: From c0ae012f96b506d55ebc13921e69f9f6e12224be Mon Sep 17 00:00:00 2001 From: kina0630 Date: Wed, 13 May 2026 20:02:05 +0800 Subject: [PATCH 3/4] fix: require publication git host allowlist --- .../adr/0024-remote-result-publication.md | 1 + .../docs/remote-sandbox-production.md | 15 ++- .../environment/docker_workspace_provider.py | 23 +++- .../test_docker_workspace_provider.py | 115 ++++++++++++++++++ coding-agent/tests/ui/test_http_server.py | 2 + 5 files changed, 145 insertions(+), 11 deletions(-) diff --git a/coding-agent/docs/adr/0024-remote-result-publication.md b/coding-agent/docs/adr/0024-remote-result-publication.md index 1445e1812..fa2f0a8ad 100644 --- a/coding-agent/docs/adr/0024-remote-result-publication.md +++ b/coding-agent/docs/adr/0024-remote-result-publication.md @@ -176,6 +176,7 @@ Suggested production configuration shape: enabled = true git_author_name = "coding-agent" git_author_email = "coding-agent@example.com" +allowed_git_hosts = ["github.com"] git_token_env = "CODING_AGENT_GIT_TOKEN" [remote_publication.github] diff --git a/coding-agent/docs/remote-sandbox-production.md b/coding-agent/docs/remote-sandbox-production.md index c04f93468..b1379edd4 100644 --- a/coding-agent/docs/remote-sandbox-production.md +++ b/coding-agent/docs/remote-sandbox-production.md @@ -82,6 +82,7 @@ secret_env_allowlist = [] enabled = true git_author_name = "coding-agent" git_author_email = "coding-agent@example.com" +allowed_git_hosts = ["github.com"] git_token_env = "CODING_AGENT_GIT_TOKEN" ``` @@ -237,12 +238,14 @@ coding-agent remote publish team --session --branch coding-agent/se ``` Branch publication requires `[remote_publication]` to be enabled, Git author -identity to be configured, and server-side Git credentials. 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 -creation is not part of this slice. +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. GitHub PR creation is not part of this +slice. Snapshot sessions can still be inspected and exported, but they are not branch publication inputs because snapshots intentionally exclude `.git`. 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 d9066454f..a894ff06d 100644 --- a/coding-agent/src/coding_agent/environment/docker_workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/docker_workspace_provider.py @@ -1310,16 +1310,29 @@ def _validate_publication_remote_url( raise ValueError("Git workspace remote.origin.url must use https") if parsed.hostname is None: raise ValueError("Git workspace remote.origin.url must include a host") - allowed_hosts = _string_list( - publication_config.get("allowed_git_hosts"), - key="remote_publication.allowed_git_hosts", - ) - if allowed_hosts and parsed.hostname not in allowed_hosts: + allowed_git_hosts = publication_config.get("allowed_git_hosts") + if allowed_git_hosts is None: + raise ValueError("remote_publication.allowed_git_hosts must be configured") + allowed_hosts = _publication_string_list(allowed_git_hosts, key="allowed_git_hosts") + if not allowed_hosts: + raise ValueError("remote_publication.allowed_git_hosts must be configured") + if parsed.hostname not in allowed_hosts: raise ValueError( "Git workspace remote.origin.url host is not in remote_publication.allowed_git_hosts" ) +def _publication_string_list(value: object, *, key: str) -> tuple[str, ...]: + if not isinstance(value, list): + raise ValueError(f"remote_publication.{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_publication.{key} must be a list of strings") + result.append(item.strip().lower()) + return tuple(result) + + def _required_publication_string( config: dict[str, object], key: str, 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 9c4b71699..dd3611f68 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 @@ -1203,6 +1203,7 @@ def fake_run( "git_author_name": "coding-agent", "git_author_email": "coding-agent@example.com", "git_token_env": "CODING_AGENT_GIT_TOKEN", + "allowed_git_hosts": ["github.com"], }, "ws-123", "coding-agent/test", @@ -1276,6 +1277,120 @@ def fake_run( "enabled": True, "git_author_name": "coding-agent", "git_author_email": "coding-agent@example.com", + "allowed_git_hosts": ["github.com"], + }, + "ws-123", + "coding-agent/test", + "Apply coding-agent remote session sess-123 changes", + ) + + +def test_docker_workspace_provider_requires_git_remote_host_allowlist_before_push( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + (workspace_dir / ".git").mkdir(parents=True) + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + del kwargs + 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, + 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.allowed_git_hosts must be configured" + ): + _ = 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", + }, + "ws-123", + "coding-agent/test", + "Apply coding-agent remote session sess-123 changes", + ) + + +def test_docker_workspace_provider_rejects_unallowlisted_git_remote_host_before_push( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + (workspace_dir / ".git").mkdir(parents=True) + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + del kwargs + 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, + 0, + stdout="https://gitlab.example.com/org/repo.git\n", + stderr="", + ) + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(ValueError, match="host is not in"): + _ = 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", + "allowed_git_hosts": ["github.com"], }, "ws-123", "coding-agent/test", diff --git a/coding-agent/tests/ui/test_http_server.py b/coding-agent/tests/ui/test_http_server.py index 13cd01fca..b7926d896 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -3663,6 +3663,7 @@ def fake_publish( "enabled": True, "git_author_name": "coding-agent", "git_author_email": "coding-agent@example.com", + "allowed_git_hosts": ["github.com"], }, ) @@ -3678,6 +3679,7 @@ def fake_publish( "enabled": True, "git_author_name": "coding-agent", "git_author_email": "coding-agent@example.com", + "allowed_git_hosts": ["github.com"], }, "ws-publish", "coding-agent/result", From 11b30e4218e8b290d136fa8408d5bfe9c8552871 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Wed, 13 May 2026 20:12:52 +0800 Subject: [PATCH 4/4] test: harden publication auth assertions --- .../environment/test_docker_workspace_provider.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 dd3611f68..4af7b0d57 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 @@ -1218,6 +1218,8 @@ def fake_run( assert push_env is not None assert push_env["GIT_CONFIG_COUNT"] == "1" assert push_env["GIT_CONFIG_KEY_0"] == "http.extraheader" + assert push_env["GIT_CONFIG_VALUE_0"].startswith("Authorization: Basic ") + assert push_env["GIT_CONFIG_VALUE_0"] != "Authorization: Basic " assert "secret-token" not in push_env["GIT_CONFIG_VALUE_0"] assert commands[-1] == [ "git", @@ -1327,7 +1329,7 @@ def fake_run( monkeypatch.setattr(subprocess, "run", fake_run) with pytest.raises( - ValueError, match="remote_publication.allowed_git_hosts must be configured" + ValueError, match=r"remote_publication\.allowed_git_hosts must be configured" ): _ = publish_workspace_branch_from_config( _docker_config({"workspace_root": str(workspace_root)}),